Инкрементальный индекс, пресеты автосейва, диалог Настройки
- VaultIndex: updateFile/removeFile/syncIncremental (mtime+size), полный рескан только на старте/смене/внешних изменениях; relink висячих [[ссылок]] при создании файла; FileStore.externalChanged для внешних правок - QML: сейвы и правки идут точечно, дерево — через syncIncremental - Настройки: пресеты safe/balanced/eco/manual (диск 2/5/10с/выкл, коммит после паузы 5/15/60с/вручную), диалог с +/-, шкалами и подсказкой; коммит только в простое, в manual — по Ctrl+S - Удалены highlightedCode, словный merge, fileDiffText/Html, 2-arg fileHistory; renderInner без побочек - Тесты: инкремент индекса, пресеты, autosaveEnabled, ~~~-блоки
This commit is contained in:
@@ -131,6 +131,7 @@ qt_add_qml_module(flora
|
||||
qml/dialogs/ConfirmDialog.qml
|
||||
qml/dialogs/TableEditor.qml
|
||||
qml/dialogs/HistoryDialog.qml
|
||||
qml/dialogs/SettingsDialog.qml
|
||||
)
|
||||
|
||||
# Theme как синглтон
|
||||
|
||||
@@ -49,6 +49,15 @@ ApplicationWindow {
|
||||
// статус можно показать в UI, пока просто игнорируем
|
||||
}
|
||||
|
||||
function applyAutosavePreset() {
|
||||
if (settingsStore.autosaveEnabled) {
|
||||
documentManager.setAutosaveIntervalMs(settingsStore.autosaveDiskMs)
|
||||
documentManager.setAutosaveEnabled(true)
|
||||
} else {
|
||||
documentManager.setAutosaveEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (windowVault.length > 0) {
|
||||
fileStore.openVault(windowVault)
|
||||
@@ -58,6 +67,11 @@ ApplicationWindow {
|
||||
if (vaultManager.vaults.length === 0) {
|
||||
welcomeWizard.open()
|
||||
}
|
||||
applyAutosavePreset()
|
||||
}
|
||||
Connections {
|
||||
target: settingsStore
|
||||
function onAutosavePresetChanged() { applyAutosavePreset() }
|
||||
}
|
||||
|
||||
menuBar: MenuBar {
|
||||
@@ -72,6 +86,7 @@ ApplicationWindow {
|
||||
Action { text: qsTr("В PDF…"); enabled: false }
|
||||
}
|
||||
MenuSeparator { }
|
||||
Action { text: qsTr("Настройки…"); onTriggered: settingsDialog.open() }
|
||||
Action { text: qsTr("Выход"); onTriggered: window.close() }
|
||||
}
|
||||
Menu {
|
||||
@@ -164,6 +179,10 @@ ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDialog {
|
||||
id: settingsDialog
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: vaultSwitcher
|
||||
x: 12
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Flora
|
||||
|
||||
Dialog {
|
||||
id: root
|
||||
|
||||
// Наведённый вариант (для живой подсказки), иначе — выбранный
|
||||
property string hoveredPreset: ""
|
||||
function presetById(id) {
|
||||
for (let i = 0; i < presets.length; ++i)
|
||||
if (presets[i].id === id) return presets[i]
|
||||
return presets[1]
|
||||
}
|
||||
property var shownPreset: presetById(hoveredPreset.length > 0 ? hoveredPreset : settingsStore.autosavePreset)
|
||||
|
||||
property var presets: [
|
||||
{
|
||||
id: "safe", name: qsTr("Максимум сохранности"),
|
||||
desc: qsTr("На диск — каждые 2 с, версия в историю — после 5 с паузы"),
|
||||
pros: [qsTr("Текст почти невозможно потерять"), qsTr("Подробная история — легко откатить любой момент")],
|
||||
cons: [qsTr("Больше коммитов при долгой печати"), qsTr("Чаще пишет на диск")],
|
||||
meters: [["disk", 5], ["commits", 4], ["load", 4]]
|
||||
},
|
||||
{
|
||||
id: "balanced", name: qsTr("Сбалансированный"),
|
||||
desc: qsTr("На диск — каждые 5 с, версия в историю — после 15 с паузы"),
|
||||
pros: [qsTr("Разумный баланс потерь и чистоты истории"), qsTr("Версии только после паузы — без мусора")],
|
||||
cons: [qsTr("До 5 секунд текста живёт только в памяти")],
|
||||
meters: [["disk", 3], ["commits", 3], ["load", 3]]
|
||||
},
|
||||
{
|
||||
id: "eco", name: qsTr("Экономный"),
|
||||
desc: qsTr("На диск — каждые 10 с, версия в историю — после минуты паузы"),
|
||||
pros: [qsTr("Минимум нагрузки — для слабых дисков и огромных хранилищ"), qsTr("История чистая, только вехи")],
|
||||
cons: [qsTr("До 10 секунд текста живёт только в памяти"), qsTr("Промежуточные мысли в историю не попадут")],
|
||||
meters: [["disk", 2], ["commits", 2], ["load", 2]]
|
||||
},
|
||||
{
|
||||
id: "manual", name: qsTr("Вручную"),
|
||||
desc: qsTr("Без таймеров: Ctrl+S сохраняет на диск и делает версию"),
|
||||
pros: [qsTr("Полный контроль, ноль фоновой работы")],
|
||||
cons: [qsTr("Забыли Ctrl+S — потеряли"), qsTr("История растёт только от ваших рук")],
|
||||
meters: [["disk", 1], ["commits", 1], ["load", 1]]
|
||||
}
|
||||
]
|
||||
|
||||
function meterName(key) {
|
||||
if (key === "disk") return qsTr("Запись на диск")
|
||||
if (key === "commits") return qsTr("Коммиты в историю")
|
||||
return qsTr("Фоновая нагрузка")
|
||||
}
|
||||
|
||||
title: qsTr("Настройки")
|
||||
modal: true
|
||||
width: 640
|
||||
height: 620
|
||||
anchors.centerIn: parent
|
||||
closePolicy: Dialog.CloseOnEscape | Dialog.CloseOnPressOutside
|
||||
standardButtons: Dialog.Close
|
||||
background: Rectangle { color: Theme.surface; border.color: Theme.borderStrong; radius: 10 }
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
// Подсказка: как читать шкалу
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
color: Theme.bgAlt
|
||||
border.color: Theme.border
|
||||
radius: 8
|
||||
Layout.preferredHeight: hintLabel.implicitHeight + 16
|
||||
Label {
|
||||
id: hintLabel
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 11
|
||||
text: qsTr("Как выбирать: чем меньше галочек — тем проще и легче решение. " +
|
||||
"Берите самое простое, которого вам хватает: обычно это «Сбалансированный», " +
|
||||
"для слабого железа или огромных хранилищ — «Экономный».")
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: qsTr("Автосохранение")
|
||||
font.bold: true
|
||||
color: Theme.text
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
ButtonGroup { id: presetGroup }
|
||||
|
||||
Repeater {
|
||||
model: root.presets
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
height: 52
|
||||
radius: 8
|
||||
color: modelData.id === settingsStore.autosavePreset ? Theme.surfaceSelected
|
||||
: (rowHover.hovered ? Theme.surfaceHover : Theme.bgAlt)
|
||||
border.color: modelData.id === settingsStore.autosavePreset ? Theme.accent : Theme.border
|
||||
border.width: modelData.id === settingsStore.autosavePreset ? 1 : 0
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 8
|
||||
RadioButton {
|
||||
checked: modelData.id === settingsStore.autosavePreset
|
||||
ButtonGroup.group: presetGroup
|
||||
onClicked: settingsStore.autosavePreset = modelData.id
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
text: modelData.name
|
||||
font.bold: true
|
||||
color: Theme.text
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Label {
|
||||
text: modelData.desc
|
||||
color: Theme.textMuted
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
HoverHandler {
|
||||
id: rowHover
|
||||
onHoveredChanged: {
|
||||
if (hovered) root.hoveredPreset = modelData.id
|
||||
else if (root.hoveredPreset === modelData.id) root.hoveredPreset = ""
|
||||
}
|
||||
}
|
||||
TapHandler {
|
||||
onTapped: settingsStore.autosavePreset = modelData.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Детали: плюсы/минусы + шкалы выбранного (или наведённого) варианта
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.bgAlt
|
||||
border.color: Theme.border
|
||||
radius: 8
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 6
|
||||
Label {
|
||||
text: root.shownPreset.name
|
||||
font.bold: true
|
||||
color: Theme.text
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Repeater {
|
||||
model: root.shownPreset.pros
|
||||
delegate: Label {
|
||||
required property string modelData
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.successText
|
||||
font.pixelSize: 11
|
||||
text: "+ " + modelData
|
||||
}
|
||||
}
|
||||
Repeater {
|
||||
model: root.shownPreset.cons
|
||||
delegate: Label {
|
||||
required property string modelData
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.Wrap
|
||||
color: Theme.errorText
|
||||
font.pixelSize: 11
|
||||
text: "− " + modelData
|
||||
}
|
||||
}
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.divider }
|
||||
Repeater {
|
||||
model: root.shownPreset.meters
|
||||
delegate: RowLayout {
|
||||
id: meterRow
|
||||
required property var modelData
|
||||
property string meterKey: modelData[0]
|
||||
property int meterLevel: modelData[1]
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Label {
|
||||
text: root.meterName(meterRow.meterKey)
|
||||
color: Theme.textSecondary
|
||||
font.pixelSize: 11
|
||||
Layout.preferredWidth: 140
|
||||
}
|
||||
Row {
|
||||
spacing: 2
|
||||
Repeater {
|
||||
model: 5
|
||||
Label {
|
||||
required property int index
|
||||
text: "✓"
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
color: index < meterRow.meterLevel ? Theme.accent : Theme.textFaint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-13
@@ -782,23 +782,26 @@ Item {
|
||||
}
|
||||
refreshGitStatus()
|
||||
}
|
||||
function armAutoCommit(path) {
|
||||
// Коммит только после паузы в печати (idle), а не на каждое сохранение
|
||||
if (!settingsStore.autocommitEnabled || !fileStore.ready || !path || path.length === 0)
|
||||
return
|
||||
autoCommitDebounce.filePath = path
|
||||
autoCommitDebounce.restart()
|
||||
}
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onActiveIndexChanged() { root.loadContent() }
|
||||
function onContentChanged(idx) { if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent(); refreshGitStatus() }
|
||||
function onContentChanged(idx) {
|
||||
if (idx === documentManager.activeIndex && !textArea.activeFocus) root.loadContent()
|
||||
refreshGitStatus()
|
||||
if (idx >= 0) root.armAutoCommit(documentManager.pathAt(idx))
|
||||
}
|
||||
function onOpenTabsChanged() { root.loadContent() }
|
||||
function onDirtyFlagChanged() { refreshGitStatus() }
|
||||
function onDocumentSaved(idx) {
|
||||
refreshGitStatus()
|
||||
// Авто-коммит для простого пользователя — каждый сохранённый файл попадает в историю
|
||||
if (fileStore.ready && idx >= 0) {
|
||||
const p = documentManager.pathAt(idx)
|
||||
if (p && p.length>0) {
|
||||
// Небольшая задержка чтобы не спамить на каждый автосейв
|
||||
autoCommitDebounce.filePath = p
|
||||
autoCommitDebounce.restart()
|
||||
}
|
||||
}
|
||||
if (idx >= 0) root.armAutoCommit(documentManager.pathAt(idx))
|
||||
}
|
||||
}
|
||||
Connections { target: fileStore; function onVaultChanged() { refreshGitStatus(); loadContent() } }
|
||||
@@ -806,9 +809,9 @@ Item {
|
||||
Timer {
|
||||
id: autoCommitDebounce
|
||||
property string filePath: ""
|
||||
interval: 2000
|
||||
interval: settingsStore.autocommitIdleMs
|
||||
onTriggered: {
|
||||
if (fileStore.ready && filePath.length>0) {
|
||||
if (settingsStore.autocommitEnabled && fileStore.ready && filePath.length>0) {
|
||||
gitManager.autoCommit(fileStore.vault, filePath)
|
||||
refreshGitStatus()
|
||||
}
|
||||
@@ -996,7 +999,16 @@ Item {
|
||||
|
||||
TableEditor { id: tableEditor; onMarkdownReady: (md) => { textArea.insert(textArea.cursorPosition, md); documentManager.updateContent(documentManager.activeIndex, textArea.text) } }
|
||||
|
||||
Shortcut { sequence: "Ctrl+S"; onActivated: { documentManager.saveAll(); refreshGitStatus() } }
|
||||
Shortcut { sequence: "Ctrl+S"; onActivated: {
|
||||
const dirty = []
|
||||
for (let i = 0; i < documentManager.openTabs.length; ++i)
|
||||
if (documentManager.isDirty(i)) dirty.push(documentManager.pathAt(i))
|
||||
documentManager.saveAll()
|
||||
// В ручном режиме Ctrl+S и сохраняет, и коммитит изменённые файлы
|
||||
if (!settingsStore.autosaveEnabled && fileStore.ready)
|
||||
for (const p of dirty) gitManager.autoCommit(fileStore.vault, p)
|
||||
refreshGitStatus()
|
||||
} }
|
||||
Shortcut { sequence: "Ctrl+W"; onActivated: documentManager.closeTab(documentManager.activeIndex) }
|
||||
Shortcut { sequence: "Ctrl+H"; onActivated: {
|
||||
const p = documentManager.pathAt(documentManager.activeIndex)
|
||||
|
||||
@@ -151,6 +151,7 @@ Item {
|
||||
if (drop.source && drop.source.dragPath && fileStore.ready) {
|
||||
const err = fileStore.moveItem(drop.source.dragPath, fileStore.vault)
|
||||
if (err.length > 0) showStatus(err, false)
|
||||
else vaultIndex.syncIncremental()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,6 +283,7 @@ Item {
|
||||
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)
|
||||
else vaultIndex.syncIncremental()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,9 +441,14 @@ Item {
|
||||
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)
|
||||
if (err.length>0) {
|
||||
showStatus(err,false)
|
||||
} else {
|
||||
if (pendingCreate.kind!=="rename") showStatus(qsTr("Создано: %1").arg(name), true)
|
||||
vaultIndex.syncIncremental()
|
||||
}
|
||||
pendingCreate.parent=""
|
||||
}
|
||||
}
|
||||
ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else showStatus(qsTr("Удалено"), true) } }
|
||||
ConfirmDialog { id: deleteDialog; onConfirmed: { const err=fileStore.removeItem(deleteTarget.path); if(err.length>0) showStatus(err,false); else { showStatus(qsTr("Удалено"), true); vaultIndex.syncIncremental() } } }
|
||||
}
|
||||
|
||||
@@ -26,16 +26,31 @@ Item {
|
||||
target: fileStore
|
||||
function onVaultChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
function onReadyChanged() { if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
// Внешнее изменение (синхронизация/другая программа): досинхронизировать индекс
|
||||
// по mtime — дёшево, без полного рескана
|
||||
function onExternalChanged() { if (fileStore.ready) vaultIndex.syncIncremental() }
|
||||
}
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||||
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); rebuildDebounce.restart() } }
|
||||
function onDocumentSaved() { rebuildDebounce.restart() }
|
||||
function onContentChanged(i) { if (i === documentManager.activeIndex) { root.refreshLinksTags(); indexDebounce.restart() } }
|
||||
function onDocumentSaved(idx, path) {
|
||||
if (fileStore.ready && path && path.length > 0) vaultIndex.updateFile(path)
|
||||
}
|
||||
}
|
||||
Connections { target: vaultIndex; function onIndexChanged() { root.refreshLinksTags() } }
|
||||
Timer { id: rebuildDebounce; interval: 800; onTriggered: if (fileStore.ready) vaultIndex.rebuild(fileStore.vault) }
|
||||
// Правка активного файла: обновить только его (один парс), а не всё хранилище
|
||||
Timer {
|
||||
id: indexDebounce
|
||||
interval: 800
|
||||
onTriggered: {
|
||||
if (fileStore.ready && documentManager.activeIndex >= 0) {
|
||||
const p = documentManager.pathAt(documentManager.activeIndex)
|
||||
if (p && p.length > 0) vaultIndex.updateFile(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Делаем панели ресайзабельными внутри правой колонки — увеличенная зона по всей ширине
|
||||
SplitView {
|
||||
|
||||
@@ -63,6 +63,21 @@ void DocumentManager::setSideBySide(bool on)
|
||||
emit sideBySideChanged();
|
||||
}
|
||||
|
||||
bool DocumentManager::autosaveEnabled() const { return m_autosaveEnabled; }
|
||||
void DocumentManager::setAutosaveEnabled(bool on)
|
||||
{
|
||||
if (m_autosaveEnabled == on)
|
||||
return;
|
||||
m_autosaveEnabled = on;
|
||||
if (m_autosave) {
|
||||
if (on)
|
||||
m_autosave->start();
|
||||
else
|
||||
m_autosave->stop();
|
||||
}
|
||||
emit autosaveEnabledChanged();
|
||||
}
|
||||
|
||||
int DocumentManager::autosaveIntervalMs() const { return m_autosaveMs; }
|
||||
void DocumentManager::setAutosaveIntervalMs(int ms)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,8 @@ class DocumentManager : public QObject
|
||||
Q_PROPERTY(bool sideBySide READ sideBySide WRITE setSideBySide NOTIFY sideBySideChanged)
|
||||
Q_PROPERTY(int autosaveIntervalMs READ autosaveIntervalMs WRITE setAutosaveIntervalMs
|
||||
NOTIFY autosaveIntervalChanged)
|
||||
Q_PROPERTY(bool autosaveEnabled READ autosaveEnabled WRITE setAutosaveEnabled
|
||||
NOTIFY autosaveEnabledChanged)
|
||||
Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged)
|
||||
|
||||
public:
|
||||
@@ -31,6 +33,8 @@ public:
|
||||
Q_INVOKABLE void setSideBySide(bool on);
|
||||
int autosaveIntervalMs() const;
|
||||
Q_INVOKABLE void setAutosaveIntervalMs(int ms);
|
||||
bool autosaveEnabled() const;
|
||||
Q_INVOKABLE void setAutosaveEnabled(bool on);
|
||||
QString lastStatus() const;
|
||||
|
||||
// Открывает документ (при необходимости создаётся вкладка). Возвращает индекс.
|
||||
@@ -60,6 +64,7 @@ signals:
|
||||
void secondaryIndexChanged();
|
||||
void sideBySideChanged();
|
||||
void autosaveIntervalChanged();
|
||||
void autosaveEnabledChanged();
|
||||
void lastStatusChanged();
|
||||
|
||||
// Уведомляет вкладку о смене признака «изменено».
|
||||
@@ -89,6 +94,7 @@ public:
|
||||
int m_secondary = -1;
|
||||
bool m_sideBySide = false;
|
||||
int m_autosaveMs = 5000;
|
||||
bool m_autosaveEnabled = true;
|
||||
QString m_lastStatus;
|
||||
QTimer *m_autosave = nullptr;
|
||||
};
|
||||
@@ -12,7 +12,7 @@ FileStore::FileStore(QObject *parent)
|
||||
{
|
||||
m_debounce.setSingleShot(true);
|
||||
m_debounce.setInterval(300);
|
||||
connect(&m_debounce, &QTimer::timeout, this, &FileStore::rebuild);
|
||||
connect(&m_debounce, &QTimer::timeout, this, &FileStore::onExternalTimeout);
|
||||
connect(&m_watcher, &QFileSystemWatcher::directoryChanged,
|
||||
this, &FileStore::scheduleRebuild);
|
||||
}
|
||||
@@ -223,6 +223,12 @@ void FileStore::scheduleRebuild()
|
||||
m_debounce.start();
|
||||
}
|
||||
|
||||
void FileStore::onExternalTimeout()
|
||||
{
|
||||
rebuild();
|
||||
emit externalChanged();
|
||||
}
|
||||
|
||||
void FileStore::toggleExpand(int row)
|
||||
{
|
||||
if (row < 0 || row >= m_rows.size() || !m_rows[row].isDir)
|
||||
|
||||
@@ -65,6 +65,9 @@ signals:
|
||||
void readyChanged();
|
||||
void fileActivated(const QString &path);
|
||||
void statusMessage(const QString &message);
|
||||
// Сработало внешнее изменение (синхронизация/другая программа) — дерево уже
|
||||
// перестроено; собственные операции этот сигнал не вызывают.
|
||||
void externalChanged();
|
||||
|
||||
private:
|
||||
struct Row {
|
||||
@@ -78,6 +81,7 @@ private:
|
||||
};
|
||||
|
||||
void rebuild();
|
||||
void onExternalTimeout();
|
||||
void collectDir(const QString &dir, int depth, QVector<Row> &out) const;
|
||||
bool isIgnored(const QString &path) const;
|
||||
int rowOf(const QString &path) const;
|
||||
|
||||
@@ -14,6 +14,7 @@ constexpr auto kKeyTheme = "ui/theme";
|
||||
constexpr auto kKeyLeftW = "ui/leftPanelWidth";
|
||||
constexpr auto kKeyRightW = "ui/rightPanelWidth";
|
||||
constexpr auto kKeyViewModes = "ui/viewModes";
|
||||
constexpr auto kKeyAutosavePreset = "autosave/preset";
|
||||
|
||||
QString encodeKey(const QString &s) {
|
||||
// Процент-кодирование чтобы избежать интерпретации "/" как разделителя групп в QSettings Ini
|
||||
@@ -276,4 +277,53 @@ void SettingsStore::setViewModeForFile(const QString &vault, const QString &file
|
||||
root.insert(encKey, cleanMode);
|
||||
s.setValue(kKeyViewModes, root);
|
||||
s.sync();
|
||||
}
|
||||
}
|
||||
QString SettingsStore::autosavePreset() const
|
||||
{
|
||||
QSettings s;
|
||||
const QString p = s.value(kKeyAutosavePreset, QStringLiteral("balanced")).toString();
|
||||
if (p == "safe" || p == "balanced" || p == "eco" || p == "manual")
|
||||
return p;
|
||||
return QStringLiteral("balanced");
|
||||
}
|
||||
|
||||
void SettingsStore::setAutosavePreset(const QString &p)
|
||||
{
|
||||
QString clean = p.trimmed();
|
||||
if (clean != "safe" && clean != "balanced" && clean != "eco" && clean != "manual")
|
||||
clean = QStringLiteral("balanced");
|
||||
QSettings s;
|
||||
if (s.value(kKeyAutosavePreset).toString() == clean)
|
||||
return;
|
||||
s.setValue(kKeyAutosavePreset, clean);
|
||||
s.sync();
|
||||
emit autosavePresetChanged();
|
||||
}
|
||||
|
||||
int SettingsStore::autosaveDiskMs() const
|
||||
{
|
||||
const QString p = autosavePreset();
|
||||
if (p == "safe") return 2000;
|
||||
if (p == "eco") return 10000;
|
||||
if (p == "manual") return 0;
|
||||
return 5000; // balanced
|
||||
}
|
||||
|
||||
int SettingsStore::autocommitIdleMs() const
|
||||
{
|
||||
const QString p = autosavePreset();
|
||||
if (p == "safe") return 5000;
|
||||
if (p == "eco") return 60000;
|
||||
if (p == "manual") return 0;
|
||||
return 15000; // balanced
|
||||
}
|
||||
|
||||
bool SettingsStore::autosaveEnabled() const
|
||||
{
|
||||
return autosavePreset() != "manual";
|
||||
}
|
||||
|
||||
bool SettingsStore::autocommitEnabled() const
|
||||
{
|
||||
return autosavePreset() != "manual";
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ class SettingsStore : public QObject
|
||||
Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged)
|
||||
Q_PROPERTY(int leftPanelWidth READ leftPanelWidth WRITE setLeftPanelWidth NOTIFY leftPanelWidthChanged)
|
||||
Q_PROPERTY(int rightPanelWidth READ rightPanelWidth WRITE setRightPanelWidth NOTIFY rightPanelWidthChanged)
|
||||
// Пресет автосохранения: "safe" | "balanced" | "eco" | "manual".
|
||||
// Производные значения читает UI (таймеры) и тесты.
|
||||
Q_PROPERTY(QString autosavePreset READ autosavePreset WRITE setAutosavePreset
|
||||
NOTIFY autosavePresetChanged)
|
||||
Q_PROPERTY(int autosaveDiskMs READ autosaveDiskMs NOTIFY autosavePresetChanged)
|
||||
Q_PROPERTY(int autocommitIdleMs READ autocommitIdleMs NOTIFY autosavePresetChanged)
|
||||
Q_PROPERTY(bool autosaveEnabled READ autosaveEnabled NOTIFY autosavePresetChanged)
|
||||
Q_PROPERTY(bool autocommitEnabled READ autocommitEnabled NOTIFY autosavePresetChanged)
|
||||
|
||||
public:
|
||||
explicit SettingsStore(QObject *parent = nullptr);
|
||||
@@ -43,6 +51,13 @@ public:
|
||||
int rightPanelWidth() const;
|
||||
void setRightPanelWidth(int w);
|
||||
|
||||
QString autosavePreset() const;
|
||||
void setAutosavePreset(const QString &p);
|
||||
int autosaveDiskMs() const;
|
||||
int autocommitIdleMs() const;
|
||||
bool autosaveEnabled() const;
|
||||
bool autocommitEnabled() const;
|
||||
|
||||
Q_INVOKABLE QString viewModeForFile(const QString &vault, const QString &filePath) const;
|
||||
Q_INVOKABLE void setViewModeForFile(const QString &vault, const QString &filePath, const QString &mode);
|
||||
|
||||
@@ -53,6 +68,7 @@ signals:
|
||||
void themeChanged();
|
||||
void leftPanelWidthChanged();
|
||||
void rightPanelWidthChanged();
|
||||
void autosavePresetChanged();
|
||||
|
||||
private:
|
||||
QVariantList m_providers;
|
||||
|
||||
+259
-45
@@ -140,53 +140,11 @@ QString VaultIndex::resolveLink(const QString &fileDir, const QString &target) c
|
||||
return QString();
|
||||
}
|
||||
|
||||
void VaultIndex::rebuild(const QString &vault)
|
||||
void VaultIndex::deduplicateEdges()
|
||||
{
|
||||
if (vault.isEmpty() || !QDir(vault).exists()) {
|
||||
m_nodes.clear();
|
||||
m_edges.clear();
|
||||
m_nodeById.clear();
|
||||
m_tagsOfFile.clear();
|
||||
m_linksOfFile.clear();
|
||||
m_filesWithTag.clear();
|
||||
m_fileByBase.clear();
|
||||
m_fileCount = 0;
|
||||
emit indexChanged();
|
||||
return;
|
||||
}
|
||||
if (m_busy)
|
||||
return;
|
||||
m_busy = true;
|
||||
emit busyChanged();
|
||||
|
||||
m_nodes.clear();
|
||||
m_edges.clear();
|
||||
m_nodeById.clear();
|
||||
m_tagsOfFile.clear();
|
||||
m_linksOfFile.clear();
|
||||
m_filesWithTag.clear();
|
||||
m_fileByBase.clear();
|
||||
m_fileCount = 0;
|
||||
|
||||
// Проход 1: собрать все md-файлы и карту имён
|
||||
QStringList files;
|
||||
collectFiles(vault, 0, files);
|
||||
m_fileCount = files.size();
|
||||
for (const QString &f : files) {
|
||||
const QString base = QFileInfo(f).completeBaseName();
|
||||
const QString key = base.toLower();
|
||||
if (!base.isEmpty() && !m_fileByBase.contains(key))
|
||||
m_fileByBase.insert(key, f);
|
||||
// также храним оригинал для точного поиска
|
||||
if (!base.isEmpty() && !m_fileByBase.contains(base))
|
||||
m_fileByBase.insert(base, f);
|
||||
}
|
||||
// Проход 2: узлы/теги/ссылки
|
||||
for (const QString &f : files)
|
||||
parseFile(f);
|
||||
// Дедупликация рёбер
|
||||
QSet<QString> seen;
|
||||
QVariantList uniq;
|
||||
uniq.reserve(m_edges.size());
|
||||
for (const auto &e : std::as_const(m_edges)) {
|
||||
const QString k = e.toMap().value("from").toString() + "->" + e.toMap().value("to").toString();
|
||||
if (!seen.contains(k)) {
|
||||
@@ -195,12 +153,258 @@ void VaultIndex::rebuild(const QString &vault)
|
||||
}
|
||||
}
|
||||
m_edges = uniq;
|
||||
}
|
||||
|
||||
bool VaultIndex::shouldIndex(const QString &cleanPath) const
|
||||
{
|
||||
if (m_vault.isEmpty())
|
||||
return false;
|
||||
if (cleanPath != m_vault && !cleanPath.startsWith(m_vault + '/'))
|
||||
return false;
|
||||
const QFileInfo fi(cleanPath);
|
||||
if (!fi.exists() || !fi.isFile())
|
||||
return false;
|
||||
if (kIgnoredNames.contains(fi.fileName()))
|
||||
return false;
|
||||
return fi.suffix().compare(QLatin1String("md"), Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
VaultIndex::FileMeta VaultIndex::metaOf(const QString &cleanPath)
|
||||
{
|
||||
const QFileInfo fi(cleanPath);
|
||||
return {fi.lastModified(), fi.size()};
|
||||
}
|
||||
|
||||
void VaultIndex::purgeOutgoing(const QString &cleanPath)
|
||||
{
|
||||
// Теги файла
|
||||
const QStringList oldTags = m_tagsOfFile.take(cleanPath);
|
||||
for (const QString &tag : oldTags) {
|
||||
auto it = m_filesWithTag.find(tag);
|
||||
if (it != m_filesWithTag.end()) {
|
||||
it.value().removeAll(cleanPath);
|
||||
if (it.value().isEmpty())
|
||||
m_filesWithTag.erase(it);
|
||||
}
|
||||
}
|
||||
// Исходящие рёбра (входящие от других файлов не трогаем — они всё ещё валидны)
|
||||
const QString key = nodeKeyFor(cleanPath, true);
|
||||
QVariantList kept;
|
||||
kept.reserve(m_edges.size());
|
||||
for (const auto &e : std::as_const(m_edges)) {
|
||||
if (e.toMap().value("from").toString() != key)
|
||||
kept.append(e);
|
||||
}
|
||||
m_edges = kept;
|
||||
m_linksOfFile.remove(cleanPath);
|
||||
m_unresolvedByFile.remove(cleanPath);
|
||||
}
|
||||
|
||||
void VaultIndex::dropNode(const QString &key)
|
||||
{
|
||||
auto it = m_nodeById.find(key);
|
||||
if (it == m_nodeById.end())
|
||||
return;
|
||||
m_nodes.removeAt(it.value());
|
||||
m_nodeById.clear();
|
||||
for (int i = 0; i < m_nodes.size(); ++i)
|
||||
m_nodeById.insert(m_nodes[i].toMap().value("key").toString(), i);
|
||||
}
|
||||
|
||||
void VaultIndex::dropFile(const QString &cleanPath)
|
||||
{
|
||||
const QString key = nodeKeyFor(cleanPath, true);
|
||||
purgeOutgoing(cleanPath);
|
||||
// Входящие рёбра и ссылки других файлов на удалённый — тоже убрать
|
||||
QVariantList kept;
|
||||
kept.reserve(m_edges.size());
|
||||
for (const auto &e : std::as_const(m_edges)) {
|
||||
if (e.toMap().value("to").toString() != key)
|
||||
kept.append(e);
|
||||
}
|
||||
m_edges = kept;
|
||||
for (auto it = m_linksOfFile.begin(); it != m_linksOfFile.end(); ++it) {
|
||||
QVariantList &links = it.value();
|
||||
for (int i = links.size() - 1; i >= 0; --i) {
|
||||
if (links[i].toMap().value("path").toString() == cleanPath)
|
||||
links.removeAt(i);
|
||||
}
|
||||
}
|
||||
dropNode(key);
|
||||
// bybase-записи, указывавшие на удалённый файл
|
||||
for (auto it = m_fileByBase.begin(); it != m_fileByBase.end();) {
|
||||
if (it.value() == cleanPath)
|
||||
it = m_fileByBase.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
m_fileMeta.remove(cleanPath);
|
||||
}
|
||||
|
||||
bool VaultIndex::relinkForNewFile(const QString &newPath)
|
||||
{
|
||||
Q_UNUSED(newPath)
|
||||
// Новый файл мог починить чьи-то висячие ссылки — перепроверяем только их
|
||||
bool any = false;
|
||||
const QStringList files = m_unresolvedByFile.keys();
|
||||
for (const QString &f : files) {
|
||||
if (!m_fileMeta.contains(f)) {
|
||||
m_unresolvedByFile.remove(f);
|
||||
continue;
|
||||
}
|
||||
const QString dir = QFileInfo(f).absolutePath();
|
||||
bool nowOk = false;
|
||||
for (const QString &t : std::as_const(m_unresolvedByFile)[f]) {
|
||||
if (!resolveLink(dir, t).isEmpty()) {
|
||||
nowOk = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nowOk) {
|
||||
purgeOutgoing(f);
|
||||
parseFile(f);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (any)
|
||||
deduplicateEdges();
|
||||
return any;
|
||||
}
|
||||
|
||||
void VaultIndex::rebuild(const QString &vault)
|
||||
{
|
||||
if (vault.isEmpty() || !QDir(vault).exists()) {
|
||||
m_vault.clear();
|
||||
m_nodes.clear();
|
||||
m_edges.clear();
|
||||
m_nodeById.clear();
|
||||
m_tagsOfFile.clear();
|
||||
m_linksOfFile.clear();
|
||||
m_filesWithTag.clear();
|
||||
m_fileByBase.clear();
|
||||
m_unresolvedByFile.clear();
|
||||
m_fileMeta.clear();
|
||||
emit indexChanged();
|
||||
return;
|
||||
}
|
||||
if (m_busy)
|
||||
return;
|
||||
m_busy = true;
|
||||
emit busyChanged();
|
||||
|
||||
m_vault = QDir::cleanPath(vault);
|
||||
m_nodes.clear();
|
||||
m_edges.clear();
|
||||
m_nodeById.clear();
|
||||
m_tagsOfFile.clear();
|
||||
m_linksOfFile.clear();
|
||||
m_filesWithTag.clear();
|
||||
m_fileByBase.clear();
|
||||
m_unresolvedByFile.clear();
|
||||
m_fileMeta.clear();
|
||||
|
||||
// Проход 1: собрать все md-файлы и карту имён
|
||||
QStringList files;
|
||||
collectFiles(m_vault, 0, files);
|
||||
for (const QString &f : files) {
|
||||
const QString clean = QDir::cleanPath(f);
|
||||
const QString base = QFileInfo(clean).completeBaseName();
|
||||
const QString key = base.toLower();
|
||||
if (!base.isEmpty() && !m_fileByBase.contains(key))
|
||||
m_fileByBase.insert(key, clean);
|
||||
// также храним оригинал для точного поиска
|
||||
if (!base.isEmpty() && !m_fileByBase.contains(base))
|
||||
m_fileByBase.insert(base, clean);
|
||||
m_fileMeta.insert(clean, metaOf(clean));
|
||||
}
|
||||
// Проход 2: узлы/теги/ссылки
|
||||
for (const QString &f : files)
|
||||
parseFile(QDir::cleanPath(f));
|
||||
deduplicateEdges();
|
||||
|
||||
m_busy = false;
|
||||
emit busyChanged();
|
||||
emit indexChanged();
|
||||
}
|
||||
|
||||
void VaultIndex::updateFile(const QString &path)
|
||||
{
|
||||
if (m_vault.isEmpty())
|
||||
return;
|
||||
const QString clean = QDir::cleanPath(path);
|
||||
if (!shouldIndex(clean)) {
|
||||
// Удаление / переименование-из / не-md: просто выкинуть из индекса
|
||||
if (m_fileMeta.contains(clean)) {
|
||||
dropFile(clean);
|
||||
emit indexChanged();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const bool isNew = !m_fileMeta.contains(clean);
|
||||
const QStringList oldTags = m_tagsOfFile.value(clean);
|
||||
const QVariantList oldLinks = m_linksOfFile.value(clean);
|
||||
purgeOutgoing(clean);
|
||||
parseFile(clean);
|
||||
deduplicateEdges();
|
||||
m_fileMeta.insert(clean, metaOf(clean));
|
||||
bool changed = isNew || m_tagsOfFile.value(clean) != oldTags
|
||||
|| m_linksOfFile.value(clean) != oldLinks;
|
||||
if (isNew)
|
||||
changed = relinkForNewFile(clean) || changed;
|
||||
if (changed)
|
||||
emit indexChanged();
|
||||
}
|
||||
|
||||
void VaultIndex::removeFile(const QString &path)
|
||||
{
|
||||
if (m_vault.isEmpty())
|
||||
return;
|
||||
const QString clean = QDir::cleanPath(path);
|
||||
if (!m_fileMeta.contains(clean) && !m_nodeById.contains(nodeKeyFor(clean, true)))
|
||||
return;
|
||||
dropFile(clean);
|
||||
emit indexChanged();
|
||||
}
|
||||
|
||||
void VaultIndex::syncIncremental()
|
||||
{
|
||||
if (m_vault.isEmpty() || !QDir(m_vault).exists() || m_busy)
|
||||
return;
|
||||
// Только метаданные (без чтения содержимого) — дёшево даже на тысячах файлов
|
||||
QStringList files;
|
||||
collectFiles(m_vault, 0, files);
|
||||
QSet<QString> seen;
|
||||
seen.reserve(files.size() * 2);
|
||||
bool changed = false;
|
||||
for (const QString &f : files) {
|
||||
const QString clean = QDir::cleanPath(f);
|
||||
seen.insert(clean);
|
||||
auto it = m_fileMeta.constFind(clean);
|
||||
if (it == m_fileMeta.constEnd() || it.value() != metaOf(clean)) {
|
||||
const QStringList oldTags = m_tagsOfFile.value(clean);
|
||||
const QVariantList oldLinks = m_linksOfFile.value(clean);
|
||||
const bool isNew = (it == m_fileMeta.constEnd());
|
||||
purgeOutgoing(clean);
|
||||
parseFile(clean);
|
||||
m_fileMeta.insert(clean, metaOf(clean));
|
||||
changed = changed || isNew || m_tagsOfFile.value(clean) != oldTags
|
||||
|| m_linksOfFile.value(clean) != oldLinks;
|
||||
if (isNew)
|
||||
changed = relinkForNewFile(clean) || changed;
|
||||
}
|
||||
}
|
||||
for (auto it = m_fileMeta.constBegin(); it != m_fileMeta.constEnd(); ++it) {
|
||||
if (!seen.contains(it.key())) {
|
||||
dropFile(it.key());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
deduplicateEdges();
|
||||
emit indexChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void VaultIndex::collectFiles(const QString &dir, int depth, QStringList &out) const
|
||||
{
|
||||
if (depth > 24)
|
||||
@@ -251,8 +455,10 @@ void VaultIndex::parseFile(const QString &path)
|
||||
m_filesWithTag[tag].append(path);
|
||||
}
|
||||
|
||||
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki)
|
||||
// Ссылки [[X]] и [text](rel) (поддерживаем алиас | в wiki).
|
||||
// Неразрешённые цели запоминаем — вдруг нужный файл создадут позже.
|
||||
QVariantList links;
|
||||
QStringList unresolved;
|
||||
static const QRegularExpression wikiRe(QStringLiteral("\\[\\[([^\\]]+)\\]\\]"));
|
||||
auto wi = wikiRe.globalMatch(text);
|
||||
while (wi.hasNext()) {
|
||||
@@ -270,6 +476,8 @@ void VaultIndex::parseFile(const QString &path)
|
||||
addEdge(key, nodeKeyFor(resolved, true));
|
||||
ensureNode(nodeKeyFor(resolved, true),
|
||||
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
||||
} else if (!unresolved.contains(target)) {
|
||||
unresolved.append(target);
|
||||
}
|
||||
}
|
||||
static const QRegularExpression mdLinkRe(
|
||||
@@ -286,9 +494,15 @@ void VaultIndex::parseFile(const QString &path)
|
||||
addEdge(key, nodeKeyFor(resolved, true));
|
||||
ensureNode(nodeKeyFor(resolved, true),
|
||||
QFileInfo(resolved).fileName(), QStringLiteral("file"));
|
||||
} else if (!unresolved.contains(target)) {
|
||||
unresolved.append(target);
|
||||
}
|
||||
}
|
||||
m_linksOfFile.insert(path, links);
|
||||
if (unresolved.isEmpty())
|
||||
m_unresolvedByFile.remove(path);
|
||||
else
|
||||
m_unresolvedByFile.insert(path, unresolved);
|
||||
}
|
||||
|
||||
QStringList VaultIndex::tagsOfFile(const QString &path) const
|
||||
|
||||
+34
-3
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
#include <QHash>
|
||||
@@ -21,12 +22,22 @@ public:
|
||||
|
||||
QVariantList nodes() const { return m_nodes; }
|
||||
QVariantList edges() const { return m_edges; }
|
||||
int fileCount() const { return m_fileCount; }
|
||||
int fileCount() const { return m_fileMeta.size(); }
|
||||
bool busy() const { return m_busy; }
|
||||
|
||||
// Полное пересканирование хранилища.
|
||||
// Полное пересканирование хранилища (старт, смена хранилища, вручную, внешние изменения).
|
||||
Q_INVOKABLE void rebuild(const QString &vault);
|
||||
|
||||
// Точечно перечитать один файл (создание/сохранение/переименование-в). Дёшево.
|
||||
Q_INVOKABLE void updateFile(const QString &path);
|
||||
|
||||
// Убрать один файл из индекса (удаление/переименование-из).
|
||||
Q_INVOKABLE void removeFile(const QString &path);
|
||||
|
||||
// Сверить список файлов с диском по mtime/size и дочитать только изменённые.
|
||||
// Для внешних изменений (синхронизация, другие программы).
|
||||
Q_INVOKABLE void syncIncremental();
|
||||
|
||||
// Теги файла / ссылки файла (список {text,path} для открытия).
|
||||
Q_INVOKABLE QStringList tagsOfFile(const QString &path) const;
|
||||
Q_INVOKABLE QVariantList linksOfFile(const QString &path) const;
|
||||
@@ -41,13 +52,32 @@ signals:
|
||||
void busyChanged();
|
||||
|
||||
private:
|
||||
struct FileMeta {
|
||||
QDateTime mtime;
|
||||
qint64 size = -1;
|
||||
bool operator==(const FileMeta &o) const { return mtime == o.mtime && size == o.size; }
|
||||
bool operator!=(const FileMeta &o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
QString nodeKeyFor(const QString &path, bool isFile) const;
|
||||
void collectFiles(const QString &dir, int depth, QStringList &out) const;
|
||||
void parseFile(const QString &path);
|
||||
QString resolveLink(const QString &fileDir, const QString &target) const;
|
||||
int ensureNode(const QString &key, const QString &name, const QString &type);
|
||||
void addEdge(const QString &from, const QString &to);
|
||||
void deduplicateEdges();
|
||||
// Файл попадает в индекс?
|
||||
bool shouldIndex(const QString &cleanPath) const;
|
||||
// Снять собственные данные файла (теги/исходящие рёбра/ссылки), узел и входящие — нет.
|
||||
void purgeOutgoing(const QString &cleanPath);
|
||||
// Полностью выкинуть файл: + входящие рёбра/ссылки, узел, bybase, мета.
|
||||
void dropFile(const QString &cleanPath);
|
||||
void dropNode(const QString &key);
|
||||
// Перепарсить файлы, чьи неразрешённые ссылки могли починиться новым файлом.
|
||||
bool relinkForNewFile(const QString &newPath);
|
||||
static FileMeta metaOf(const QString &cleanPath);
|
||||
|
||||
QString m_vault;
|
||||
QVariantList m_nodes;
|
||||
QVariantList m_edges;
|
||||
QHash<QString, int> m_nodeById;
|
||||
@@ -55,6 +85,7 @@ private:
|
||||
QHash<QString, QVariantList> m_linksOfFile;
|
||||
QHash<QString, QStringList> m_filesWithTag;
|
||||
QHash<QString, QString> m_fileByBase; // имя без расширения → путь
|
||||
int m_fileCount = 0;
|
||||
QHash<QString, QStringList> m_unresolvedByFile; // файл → неразрешённые цели ссылок
|
||||
QHash<QString, FileMeta> m_fileMeta; // проиндексированные .md
|
||||
bool m_busy = false;
|
||||
};
|
||||
@@ -959,14 +959,6 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault
|
||||
return blocks;
|
||||
}
|
||||
|
||||
QString MdParser::highlightedCode(const QString &code, const QString &language) const
|
||||
{
|
||||
QString lang = language.trimmed();
|
||||
if (!m || !m->m_repo) return plainToRich(code);
|
||||
CodeHighlighter hl(m->m_repo);
|
||||
return hl.highlight(code, lang, true);
|
||||
}
|
||||
|
||||
// Потокобезопасное ядро diff-блоков: работает с явно переданным Impl
|
||||
// (в главном потоке — m, в рабочем — локальный), состояние только в нём.
|
||||
static QVariantList buildDiffBlocks(MdParser::Impl *impl, const QString &oldMd,
|
||||
|
||||
@@ -49,7 +49,6 @@ public:
|
||||
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
||||
// Скопировать кодовый блок по id (get + copy одним вызовом). false — нечего копировать.
|
||||
Q_INVOKABLE bool copyCodeBlock(const QString &id) const;
|
||||
Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const;
|
||||
|
||||
signals:
|
||||
void blocksReady(int requestId, const QVariantList &blocks);
|
||||
|
||||
@@ -129,6 +129,26 @@ int main(int argc, char *argv[])
|
||||
|| dm.isTextForEditor("/x/a.pdf"))
|
||||
return fail("isTextForEditor");
|
||||
|
||||
// --- autosaveEnabled (ручной режим настроек) ---
|
||||
if (!dm.autosaveEnabled())
|
||||
return fail("autosave enabled by default");
|
||||
{
|
||||
QTimer *t = dm.findChild<QTimer*>();
|
||||
if (!t || !t->isActive())
|
||||
return fail("autosave timer active");
|
||||
}
|
||||
dm.setAutosaveEnabled(false);
|
||||
if (dm.autosaveEnabled())
|
||||
return fail("autosave disabled");
|
||||
{
|
||||
QTimer *t = dm.findChild<QTimer*>();
|
||||
if (!t || t->isActive())
|
||||
return fail("autosave timer stopped");
|
||||
}
|
||||
dm.setAutosaveEnabled(true);
|
||||
if (!dm.autosaveEnabled())
|
||||
return fail("autosave re-enabled");
|
||||
|
||||
std::printf("DOCUMENT MANAGER TEST OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -138,11 +138,11 @@ int main(int argc, char *argv[])
|
||||
|
||||
// Подсветка: переносы строк — явные <br/>, иначе RichText склеит в одну строку
|
||||
{
|
||||
QString hl = p.highlightedCode("int a = 1;\nint b = 2;\n", "cpp");
|
||||
auto b = p.parseBlocks("```cpp\nint a = 1;\nint b = 2;\n```\n", vault, fileDir);
|
||||
if (b.size() != 1) return fail("br блок");
|
||||
const QString hl = b[0].toMap().value("highlightedText").toString();
|
||||
if (!hl.contains("<br/>")) return fail("highlighted без <br/>", hl.left(200));
|
||||
if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200));
|
||||
QString plain = p.highlightedCode("a\nb", "неизвестный-язык-xyz");
|
||||
if (!plain.contains("<br/>")) return fail("plain highlight без <br/>", plain);
|
||||
}
|
||||
|
||||
// parseBlocksWithDiff: версия old vs текущее new
|
||||
|
||||
@@ -124,6 +124,28 @@ int main(int argc, char *argv[])
|
||||
if (after.contains(vault + "|" + "old.md")) return fail("old key not migrated");
|
||||
}
|
||||
|
||||
// autosave presets
|
||||
if (s.autosavePreset() != "balanced") return fail("default preset");
|
||||
if (s.autosaveDiskMs() != 5000) return fail("balanced disk");
|
||||
if (s.autocommitIdleMs() != 15000) return fail("balanced idle");
|
||||
if (!s.autosaveEnabled() || !s.autocommitEnabled()) return fail("balanced enabled");
|
||||
s.setAutosavePreset("safe");
|
||||
if (s.autosavePreset() != "safe") return fail("safe preset");
|
||||
if (s.autosaveDiskMs() != 2000 || s.autocommitIdleMs() != 5000) return fail("safe values");
|
||||
s.setAutosavePreset("eco");
|
||||
if (s.autosaveDiskMs() != 10000 || s.autocommitIdleMs() != 60000) return fail("eco values");
|
||||
s.setAutosavePreset("manual");
|
||||
if (s.autosaveEnabled() || s.autocommitEnabled()) return fail("manual disabled");
|
||||
if (s.autosaveDiskMs() != 0 || s.autocommitIdleMs() != 0) return fail("manual zeros");
|
||||
s.setAutosavePreset(" wat? ");
|
||||
if (s.autosavePreset() != "balanced") return fail("invalid preset fallback");
|
||||
s.setAutosavePreset("eco");
|
||||
{
|
||||
SettingsStore s2;
|
||||
if (s2.autosavePreset() != "eco") return fail("preset persistence");
|
||||
if (s2.autosaveDiskMs() != 10000) return fail("preset values persistence");
|
||||
}
|
||||
|
||||
std::printf("SETTINGS TEST OK\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,75 @@ int main(int argc, char *argv[])
|
||||
if (files2.size() != 2)
|
||||
return fail("filesWithTag(тег2)", QString::number(files2.size()));
|
||||
|
||||
// --- инкремент: updateFile меняет только один файл (убираем теги из a.md) ---
|
||||
put("a.md", "# Заголовок\n\nСсылка на [[b]].\n");
|
||||
idx.updateFile(a);
|
||||
if (idx.fileCount() != 3)
|
||||
return fail("updateFile fileCount", QString::number(idx.fileCount()));
|
||||
// a.md теперь без тегов — тег1 остался только... нигде: filesWithTag пуст
|
||||
if (!idx.filesWithTag("тег1").isEmpty())
|
||||
return fail("updateFile снял тег1", idx.filesWithTag("тег1").join(","));
|
||||
if (idx.tagsOfFile(a).size() != 0)
|
||||
return fail("updateFile tagsOfFile", idx.tagsOfFile(a).join(","));
|
||||
// b.md не трогали — его теги на месте
|
||||
if (!idx.tagsOfFile(b).contains("тег2"))
|
||||
return fail("updateFile не тронул b");
|
||||
|
||||
// --- инкремент: новый файл чинит висячую ссылку (relink) ---
|
||||
put("d.md", "Ссылка на [[future]].\n");
|
||||
const QString d = dir.path() + "/d.md";
|
||||
idx.updateFile(d);
|
||||
if (idx.fileCount() != 4)
|
||||
return fail("updateFile new fileCount", QString::number(idx.fileCount()));
|
||||
if (!idx.linksOfFile(d).isEmpty())
|
||||
return fail("висячая ссылка должна быть пустой");
|
||||
put("sub/future.md", "# Future\n");
|
||||
const QString future = dir.path() + "/sub/future.md";
|
||||
idx.updateFile(future);
|
||||
{
|
||||
bool foundFuture = false;
|
||||
for (const auto &l : idx.linksOfFile(d))
|
||||
if (l.toMap().value("path").toString() == future)
|
||||
foundFuture = true;
|
||||
if (!foundFuture)
|
||||
return fail("relink не починил [[future]]");
|
||||
}
|
||||
|
||||
// --- инкремент: removeFile чистит теги/ссылки/узел ---
|
||||
// (в реальности вызывается потому что файл удалён — удаляем и с диска)
|
||||
QFile::remove(future);
|
||||
idx.removeFile(future);
|
||||
if (idx.fileCount() != 4)
|
||||
return fail("removeFile fileCount", QString::number(idx.fileCount()));
|
||||
{
|
||||
bool foundFuture = false;
|
||||
for (const auto &l : idx.linksOfFile(d))
|
||||
if (l.toMap().value("path").toString() == future)
|
||||
foundFuture = true;
|
||||
if (foundFuture)
|
||||
return fail("removeFile не убрал входящую ссылку");
|
||||
}
|
||||
// removeFile несуществующего — тихо, без падения
|
||||
idx.removeFile(dir.path() + "/нет-такого.md");
|
||||
idx.updateFile(dir.path() + "/нет-такого.md");
|
||||
|
||||
// --- инкремент: syncIncremental догоняет внешние изменения ---
|
||||
put("ext.md", "Внешний #внешний\n");
|
||||
const QString ext = dir.path() + "/ext.md";
|
||||
if (idx.fileCount() != 4)
|
||||
return fail("до sync файл не виден", QString::number(idx.fileCount()));
|
||||
idx.syncIncremental();
|
||||
if (idx.fileCount() != 5)
|
||||
return fail("sync подхватил файл", QString::number(idx.fileCount()));
|
||||
if (!idx.tagsOfFile(ext).contains("внешний"))
|
||||
return fail("sync теги");
|
||||
QFile::remove(ext);
|
||||
idx.syncIncremental();
|
||||
if (idx.fileCount() != 4)
|
||||
return fail("sync убрал файл", QString::number(idx.fileCount()));
|
||||
if (idx.tagsOfFile(ext).size() != 0)
|
||||
return fail("sync теги удалены");
|
||||
|
||||
std::printf("VAULTINDEX TEST OK\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user