Фаза 4: редактор — DocumentManager (вкладки, автосохранение 5с, сохранение при закрытии), EditorPane/EditorPanel (вкладки, режимы, «открыть рядом», Ctrl+S/W/E/Tab), открытие из дерева; тест docmgr (14/14)
This commit is contained in:
@@ -37,6 +37,7 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/third_party/deps.cmake)
|
|||||||
set(FLORA_HEADERS
|
set(FLORA_HEADERS
|
||||||
src/app/VaultManager.h
|
src/app/VaultManager.h
|
||||||
src/app/WindowController.h
|
src/app/WindowController.h
|
||||||
|
src/core/DocumentManager.h
|
||||||
src/core/FileStore.h
|
src/core/FileStore.h
|
||||||
src/core/SettingsStore.h
|
src/core/SettingsStore.h
|
||||||
src/git/GitManager.h
|
src/git/GitManager.h
|
||||||
@@ -46,6 +47,7 @@ set(FLORA_HEADERS
|
|||||||
set(FLORA_SOURCES
|
set(FLORA_SOURCES
|
||||||
src/app/VaultManager.cpp
|
src/app/VaultManager.cpp
|
||||||
src/app/WindowController.cpp
|
src/app/WindowController.cpp
|
||||||
|
src/core/DocumentManager.cpp
|
||||||
src/core/FileStore.cpp
|
src/core/FileStore.cpp
|
||||||
src/core/SettingsStore.cpp
|
src/core/SettingsStore.cpp
|
||||||
src/git/GitManager.cpp
|
src/git/GitManager.cpp
|
||||||
@@ -97,6 +99,7 @@ qt_add_qml_module(flora
|
|||||||
qml/MainWindow.qml
|
qml/MainWindow.qml
|
||||||
qml/panels/FileTreePanel.qml
|
qml/panels/FileTreePanel.qml
|
||||||
qml/panels/EditorPanel.qml
|
qml/panels/EditorPanel.qml
|
||||||
|
qml/panels/EditorPane.qml
|
||||||
qml/panels/RightPanel.qml
|
qml/panels/RightPanel.qml
|
||||||
qml/wizard/WelcomeWizard.qml
|
qml/wizard/WelcomeWizard.qml
|
||||||
qml/dialogs/ProviderSetupGuide.qml
|
qml/dialogs/ProviderSetupGuide.qml
|
||||||
|
|||||||
+5
-2
@@ -178,8 +178,11 @@ ApplicationWindow {
|
|||||||
|
|
||||||
onOpenVaultRequested: vaultSwitcher.open()
|
onOpenVaultRequested: vaultSwitcher.open()
|
||||||
onOpenFileRequested: (path) => {
|
onOpenFileRequested: (path) => {
|
||||||
// Фаза 4: открытие вкладки в редакторе. Пока — лог.
|
if (documentManager.isTextForEditor(path)) {
|
||||||
console.log("Открыть файл: " + path)
|
documentManager.openDocument(path)
|
||||||
|
} else {
|
||||||
|
leftPanel.showStatus(qsTr("Предпросмотр HTML/PDF/изображений появится в Фазе 6-10"), false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
// Панель одного документа: шапка (заголовок, режим, действия) + текст.
|
||||||
|
// Режимы: редактирование (TextArea) / просмотр (read-only TextArea).
|
||||||
|
Item {
|
||||||
|
id: pane
|
||||||
|
|
||||||
|
property int docIndex: -1
|
||||||
|
property bool editMode: true
|
||||||
|
property bool canOpenBeside: false // кнопка «открыть рядом» (заглавная панель)
|
||||||
|
property bool closable: false // кнопка «закрыть панель» (вторая панель)
|
||||||
|
|
||||||
|
signal openBeside()
|
||||||
|
signal closePane()
|
||||||
|
|
||||||
|
function refreshHeader() {
|
||||||
|
const title = documentManager.titleAt(pane.docIndex)
|
||||||
|
headerTitle.text = title + (documentManager.isDirty(pane.docIndex) ? " •" : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 30
|
||||||
|
color: "whitesmoke"
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 4
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: headerTitle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
color: "#444"
|
||||||
|
text: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключатель режимов
|
||||||
|
ToolButton {
|
||||||
|
text: "✎"
|
||||||
|
checkable: true
|
||||||
|
checked: pane.editMode
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: qsTr("Режим редактирования (Ctrl+E)")
|
||||||
|
onClicked: { pane.editMode = true; editBtn.checked = true; viewBtn.checked = false }
|
||||||
|
}
|
||||||
|
ToolButton {
|
||||||
|
id: viewBtn
|
||||||
|
text: "👁"
|
||||||
|
checkable: true
|
||||||
|
checked: !pane.editMode
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: qsTr("Режим просмотра (Ctrl+E)")
|
||||||
|
onClicked: { pane.editMode = false; editBtn.checked = false; viewBtn.checked = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolButton {
|
||||||
|
id: editBtn
|
||||||
|
visible: false
|
||||||
|
checkable: true
|
||||||
|
checked: true
|
||||||
|
onClicked: { pane.editMode = true; viewBtn.checked = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolButton {
|
||||||
|
text: "\u25B8\u25CB\u25C0" // «открыть рядом» (две панели)
|
||||||
|
visible: pane.canOpenBeside && !documentManager.sideBySide
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: qsTr("Открыть рядом (две панели)")
|
||||||
|
onClicked: pane.openBeside()
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolButton {
|
||||||
|
text: "\u2715"
|
||||||
|
visible: pane.closable
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: qsTr("Закрыть вторую панель")
|
||||||
|
onClicked: pane.closePane()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "white"
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: editor
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: pane.editMode
|
||||||
|
text: documentManager.contentAt(pane.docIndex)
|
||||||
|
onTextEdited: documentManager.updateContent(pane.docIndex, text)
|
||||||
|
selectByMouse: true
|
||||||
|
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||||
|
font.pixelSize: 14
|
||||||
|
wrapMode: TextEdit.NoWrap
|
||||||
|
padding: 10
|
||||||
|
placeholderText: qsTr("Документ не выбран")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: viewer
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: !pane.editMode
|
||||||
|
text: documentManager.contentAt(pane.docIndex)
|
||||||
|
readOnly: true
|
||||||
|
selectByMouse: true
|
||||||
|
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||||
|
font.pixelSize: 14
|
||||||
|
wrapMode: TextEdit.Wrap
|
||||||
|
padding: 12
|
||||||
|
placeholderText: qsTr("Документ не выбран")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежий заголовок при смене признака «изменено»
|
||||||
|
Connections {
|
||||||
|
target: documentManager
|
||||||
|
function onDirtyFlagChanged(i) {
|
||||||
|
if (i === pane.docIndex)
|
||||||
|
pane.refreshHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Просмотр обновляется при изменении контента (например, при правке в соседней панели)
|
||||||
|
Connections {
|
||||||
|
target: documentManager
|
||||||
|
function onContentChanged(i) {
|
||||||
|
if (i === pane.docIndex && !pane.editMode)
|
||||||
|
viewer.text = documentManager.contentAt(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDocIndexChanged: pane.refreshHeader()
|
||||||
|
Component.onCompleted: pane.refreshHeader()
|
||||||
|
}
|
||||||
+137
-22
@@ -3,60 +3,175 @@ import QtQuick.Controls
|
|||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
|
||||||
// Центральная панель: панель вкладок + редактор.
|
// Центральная панель: панель вкладок + редактор.
|
||||||
// Фаза 0 — каркас: вкладки, два столбца «открыть рядом» (задел),
|
// Поддержка двух панелей рядом («открыть рядом»): правая = вторая вкладка.
|
||||||
// переключение режимов редактирование/просмотр (задел).
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
readonly property bool editMode: true // Фаза 4: реальный переключатель
|
function baseName(p) {
|
||||||
|
const idx = p.lastIndexOf("/")
|
||||||
|
return idx >= 0 ? p.substring(idx + 1) : p
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
// Панель вкладок
|
// ----- Панель вкладок -----
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 32
|
implicitHeight: 32
|
||||||
color: "whitesmoke"
|
color: "#ececec"
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: 4
|
anchors.leftMargin: 4
|
||||||
spacing: 4
|
anchors.rightMargin: 4
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: 0 // открытые вкладки наполняются в Фазе 4
|
model: documentManager.openTabs
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: tab
|
||||||
|
required property string modelData
|
||||||
|
readonly property bool active: index === documentManager.activeIndex
|
||||||
|
width: Math.max(96, Math.min(200, tabLabel.implicitWidth + 44))
|
||||||
|
height: 26
|
||||||
|
radius: 4
|
||||||
|
color: active ? "white" : "#e0e0e0"
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 2
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: tabLabel
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
text: root.baseName(modelData)
|
||||||
|
+ (documentManager.isDirty(index) ? " •" : "")
|
||||||
|
font.pixelSize: 12
|
||||||
|
color: active ? "#111" : "#555"
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolButton {
|
||||||
|
text: "\u2715"
|
||||||
|
implicitWidth: 20
|
||||||
|
implicitHeight: 20
|
||||||
|
font.pixelSize: 9
|
||||||
|
opacity: tab.active || hovered ? 1 : 0
|
||||||
|
onClicked: documentManager.closeTab(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: documentManager.setActiveIndex(index)
|
||||||
|
onDoubleClicked: documentManager.closeTab(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
Layout.fillWidth: true
|
visible: documentManager.openTabs.length === 0
|
||||||
text: qsTr("— нет открытых файлов —")
|
text: qsTr("Откройте файл из дерева")
|
||||||
color: "#bbb"
|
color: "#999"
|
||||||
|
font.pixelSize: 12
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolButton {
|
ToolButton {
|
||||||
text: "▮▮" // «открыть рядом» (Фаза 4)
|
id: splitBtn
|
||||||
|
text: "\u25A1\u25A1"
|
||||||
|
checkable: true
|
||||||
|
checked: documentManager.sideBySide
|
||||||
|
enabled: documentManager.openTabs.length > 0
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
ToolTip.text: qsTr("Открыть рядом (две панели)")
|
ToolTip.text: qsTr("Открыть рядом (две панели)")
|
||||||
enabled: false
|
onToggled: {
|
||||||
|
if (checked) {
|
||||||
|
documentManager.setSecondaryIndex(documentManager.activeIndex)
|
||||||
|
documentManager.setSideBySide(true)
|
||||||
|
} else {
|
||||||
|
documentManager.setSideBySide(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Область редактирования
|
// ----- Область редактора (одна или две панели) -----
|
||||||
Rectangle {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: "white"
|
spacing: 0
|
||||||
Label {
|
|
||||||
anchors.centerIn: parent
|
EditorPane {
|
||||||
text: root.editMode
|
id: primaryPane
|
||||||
? qsTr("Редактор\nОткройте файл из дерева (Фаза 4)")
|
Layout.fillWidth: true
|
||||||
: qsTr("Просмотр\n(Фаза 4)")
|
docIndex: documentManager.activeIndex
|
||||||
horizontalAlignment: Text.AlignHCenter
|
canOpenBeside: true
|
||||||
color: "#bbb"
|
onOpenBeside: {
|
||||||
|
documentManager.setSecondaryIndex(documentManager.activeIndex)
|
||||||
|
documentManager.setSideBySide(true)
|
||||||
|
splitBtn.checked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: documentManager.sideBySide
|
||||||
|
Layout.fillHeight: true
|
||||||
|
width: 1
|
||||||
|
color: "#ccc"
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorPane {
|
||||||
|
id: secondaryPane
|
||||||
|
visible: documentManager.sideBySide
|
||||||
|
Layout.preferredWidth: 360
|
||||||
|
Layout.maximumWidth: 640
|
||||||
|
docIndex: documentManager.secondaryIndex
|
||||||
|
closable: true
|
||||||
|
onClosePane: {
|
||||||
|
documentManager.setSideBySide(false)
|
||||||
|
splitBtn.checked = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Горячие клавиши -----
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+S"
|
||||||
|
context: Qt.WindowShortcut
|
||||||
|
onActivated: {
|
||||||
|
documentManager.saveAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+W"
|
||||||
|
context: Qt.WindowShortcut
|
||||||
|
onActivated: {
|
||||||
|
documentManager.closeTab(documentManager.activeIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+E"
|
||||||
|
context: Qt.WindowShortcut
|
||||||
|
onActivated: {
|
||||||
|
primaryPane.editMode = !primaryPane.editMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Shortcut {
|
||||||
|
sequence: "Ctrl+Tab"
|
||||||
|
context: Qt.WindowShortcut
|
||||||
|
onActivated: {
|
||||||
|
const n = documentManager.openTabs.length
|
||||||
|
if (n > 0)
|
||||||
|
documentManager.setActiveIndex((documentManager.activeIndex + 1) % n)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#include "core/DocumentManager.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QTextStream>
|
||||||
|
|
||||||
|
DocumentManager::DocumentManager(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
QTimer *autosave = new QTimer(this);
|
||||||
|
autosave->setInterval(m_autosaveMs);
|
||||||
|
autosave->setSingleShot(false);
|
||||||
|
connect(autosave, &QTimer::timeout, this, &DocumentManager::saveAll);
|
||||||
|
autosave->start();
|
||||||
|
|
||||||
|
// Защита при выходе
|
||||||
|
connect(qApp, &QCoreApplication::aboutToQuit, this, &DocumentManager::saveAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList DocumentManager::openTabs() const
|
||||||
|
{
|
||||||
|
QStringList list;
|
||||||
|
list.reserve(m_docs.size());
|
||||||
|
for (const Doc &d : m_docs)
|
||||||
|
list.append(d.path);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
int DocumentManager::activeIndex() const { return m_active; }
|
||||||
|
void DocumentManager::setActiveIndex(int index)
|
||||||
|
{
|
||||||
|
if (m_active == index)
|
||||||
|
return;
|
||||||
|
m_active = index;
|
||||||
|
emit activeIndexChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
int DocumentManager::secondaryIndex() const { return m_secondary; }
|
||||||
|
void DocumentManager::setSecondaryIndex(int index)
|
||||||
|
{
|
||||||
|
if (m_secondary == index)
|
||||||
|
return;
|
||||||
|
m_secondary = index;
|
||||||
|
emit secondaryIndexChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DocumentManager::sideBySide() const { return m_sideBySide; }
|
||||||
|
void DocumentManager::setSideBySide(bool on)
|
||||||
|
{
|
||||||
|
if (m_sideBySide == on)
|
||||||
|
return;
|
||||||
|
m_sideBySide = on;
|
||||||
|
emit sideBySideChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
int DocumentManager::autosaveIntervalMs() const { return m_autosaveMs; }
|
||||||
|
void DocumentManager::setAutosaveIntervalMs(int ms)
|
||||||
|
{
|
||||||
|
if (m_autosaveMs == ms)
|
||||||
|
return;
|
||||||
|
m_autosaveMs = ms;
|
||||||
|
emit autosaveIntervalChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DocumentManager::lastStatus() const { return m_lastStatus; }
|
||||||
|
|
||||||
|
int DocumentManager::openDocument(const QString &path)
|
||||||
|
{
|
||||||
|
const QString clean = QFileInfo(path).absoluteFilePath();
|
||||||
|
for (int i = 0; i < m_docs.size(); ++i) {
|
||||||
|
if (m_docs[i].path == clean) {
|
||||||
|
setActiveIndex(i);
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
const QString content = readFile(clean, &ok);
|
||||||
|
if (!ok) {
|
||||||
|
m_lastStatus = QStringLiteral("Не удалось открыть файл: %1").arg(clean);
|
||||||
|
emit lastStatusChanged();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Doc doc;
|
||||||
|
doc.path = clean;
|
||||||
|
doc.content = content;
|
||||||
|
doc.dirty = false;
|
||||||
|
m_docs.append(doc);
|
||||||
|
|
||||||
|
const int idx = m_docs.size() - 1;
|
||||||
|
setActiveIndex(idx);
|
||||||
|
emit openTabsChanged();
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DocumentManager::readFile(const QString &path, bool *ok) const
|
||||||
|
{
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly)) {
|
||||||
|
if (ok) *ok = false;
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
const QByteArray bytes = f.readAll();
|
||||||
|
if (ok) *ok = true;
|
||||||
|
return QString::fromUtf8(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DocumentManager::contentAt(int index) const
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_docs.size())
|
||||||
|
return QString();
|
||||||
|
return m_docs[index].content;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DocumentManager::titleAt(int index) const
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_docs.size())
|
||||||
|
return QString();
|
||||||
|
return QFileInfo(m_docs[index].path).fileName();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DocumentManager::pathAt(int index) const
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_docs.size())
|
||||||
|
return QString();
|
||||||
|
return m_docs[index].path;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DocumentManager::isDirty(int index) const
|
||||||
|
{
|
||||||
|
return index >= 0 && index < m_docs.size() && m_docs[index].dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DocumentManager::updateContent(int index, const QString &text)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_docs.size())
|
||||||
|
return;
|
||||||
|
Doc &d = m_docs[index];
|
||||||
|
if (d.content == text)
|
||||||
|
return;
|
||||||
|
d.content = text;
|
||||||
|
d.dirty = true;
|
||||||
|
emit dirtyFlagChanged(index);
|
||||||
|
emit contentChanged(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DocumentManager::isTextForEditor(const QString &path) const
|
||||||
|
{
|
||||||
|
const QString ext = QFileInfo(path).suffix().toLower();
|
||||||
|
static const QStringList textExts = {
|
||||||
|
"md", "markdown", "txt", "log", "json", "yaml", "yml",
|
||||||
|
"c", "h", "cpp", "hpp", "cc", "cxx", "py", "go", "rs",
|
||||||
|
"js", "ts", "css", "sh", "bash", "xml", "ini", "csv", "svg"
|
||||||
|
};
|
||||||
|
return ext.isEmpty() || textExts.contains(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DocumentManager::saveDocument(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_docs.size())
|
||||||
|
return;
|
||||||
|
Doc &d = m_docs[index];
|
||||||
|
if (!d.dirty)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QSaveFile f(d.path);
|
||||||
|
if (f.open(QIODevice::WriteOnly)) {
|
||||||
|
f.write(d.content.toUtf8());
|
||||||
|
if (f.commit()) {
|
||||||
|
d.dirty = false;
|
||||||
|
emit dirtyFlagChanged(index);
|
||||||
|
emit documentSaved(index, d.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_lastStatus = QStringLiteral("Не удалось сохранить: %1").arg(d.path);
|
||||||
|
emit lastStatusChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DocumentManager::saveAll()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < m_docs.size(); ++i)
|
||||||
|
saveDocument(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DocumentManager::closeTab(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_docs.size())
|
||||||
|
return false;
|
||||||
|
// При закрытии тоже сохраняем (защита от потери текста).
|
||||||
|
saveDocument(index);
|
||||||
|
|
||||||
|
m_docs.removeAt(index);
|
||||||
|
|
||||||
|
if (m_active > index)
|
||||||
|
--m_active;
|
||||||
|
else if (m_active == index)
|
||||||
|
m_active = m_docs.isEmpty() ? -1 : qMin(index, m_docs.size() - 1);
|
||||||
|
|
||||||
|
if (m_secondary > index)
|
||||||
|
--m_secondary;
|
||||||
|
else if (m_secondary == index)
|
||||||
|
m_secondary = -1;
|
||||||
|
|
||||||
|
emit openTabsChanged();
|
||||||
|
emit activeIndexChanged();
|
||||||
|
emit secondaryIndexChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QList>
|
||||||
|
|
||||||
|
// Управление открытыми документами: вкладки, содержимое, признак «изменён»,
|
||||||
|
// автосохранение каждые 5 секунд (защита от потери текста).
|
||||||
|
// Режимы редактирование/просмотр — на стороне QML (на документ).
|
||||||
|
class DocumentManager : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
Q_PROPERTY(QStringList openTabs READ openTabs NOTIFY openTabsChanged)
|
||||||
|
Q_PROPERTY(int activeIndex READ activeIndex WRITE setActiveIndex NOTIFY activeIndexChanged)
|
||||||
|
Q_PROPERTY(int secondaryIndex READ secondaryIndex WRITE setSecondaryIndex NOTIFY secondaryIndexChanged)
|
||||||
|
Q_PROPERTY(bool sideBySide READ sideBySide WRITE setSideBySide NOTIFY sideBySideChanged)
|
||||||
|
Q_PROPERTY(int autosaveIntervalMs READ autosaveIntervalMs WRITE setAutosaveIntervalMs
|
||||||
|
NOTIFY autosaveIntervalChanged)
|
||||||
|
Q_PROPERTY(QString lastStatus READ lastStatus NOTIFY lastStatusChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit DocumentManager(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QStringList openTabs() const;
|
||||||
|
int activeIndex() const;
|
||||||
|
void setActiveIndex(int index);
|
||||||
|
int secondaryIndex() const;
|
||||||
|
void setSecondaryIndex(int index);
|
||||||
|
bool sideBySide() const;
|
||||||
|
void setSideBySide(bool on);
|
||||||
|
int autosaveIntervalMs() const;
|
||||||
|
void setAutosaveIntervalMs(int ms);
|
||||||
|
QString lastStatus() const;
|
||||||
|
|
||||||
|
// Открывает документ (при необходимости создаётся вкладка). Возвращает индекс.
|
||||||
|
Q_INVOKABLE int openDocument(const QString &path);
|
||||||
|
|
||||||
|
// Содержимое/заголовок/признак по индексу вкладки.
|
||||||
|
Q_INVOKABLE QString contentAt(int index) const;
|
||||||
|
Q_INVOKABLE QString titleAt(int index) const;
|
||||||
|
Q_INVOKABLE QString pathAt(int index) const;
|
||||||
|
Q_INVOKABLE bool isDirty(int index) const;
|
||||||
|
|
||||||
|
// Редактирование: пользователь изменил текст вкладки.
|
||||||
|
Q_INVOKABLE void updateContent(int index, const QString &text);
|
||||||
|
|
||||||
|
// Можно ли открыть файл в текстовом редакторе (md и прочие текстовые).
|
||||||
|
Q_INVOKABLE bool isTextForEditor(const QString &path) const;
|
||||||
|
|
||||||
|
Q_INVOKABLE void saveDocument(int index);
|
||||||
|
Q_INVOKABLE void saveAll();
|
||||||
|
|
||||||
|
Q_INVOKABLE bool closeTab(int index);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void openTabsChanged();
|
||||||
|
void activeIndexChanged();
|
||||||
|
void secondaryIndexChanged();
|
||||||
|
void sideBySideChanged();
|
||||||
|
void autosaveIntervalChanged();
|
||||||
|
void lastStatusChanged();
|
||||||
|
|
||||||
|
// Уведомляет вкладку о смене признака «изменено».
|
||||||
|
void dirtyFlagChanged(int index);
|
||||||
|
// Содержимое документа изменилось (вкладка index).
|
||||||
|
void contentChanged(int index);
|
||||||
|
// Файл фактически записан на диск.
|
||||||
|
void documentSaved(int index, const QString &path);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Doc {
|
||||||
|
QString path;
|
||||||
|
QString content;
|
||||||
|
bool dirty = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
void ensureAutosave();
|
||||||
|
QString readFile(const QString &path, bool *ok) const;
|
||||||
|
|
||||||
|
QList<Doc> m_docs;
|
||||||
|
int m_active = -1;
|
||||||
|
int m_secondary = -1;
|
||||||
|
bool m_sideBySide = false;
|
||||||
|
int m_autosaveMs = 5000;
|
||||||
|
QString m_lastStatus;
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include "app/VaultManager.h"
|
#include "app/VaultManager.h"
|
||||||
#include "app/WindowController.h"
|
#include "app/WindowController.h"
|
||||||
|
#include "core/DocumentManager.h"
|
||||||
#include "core/SettingsStore.h"
|
#include "core/SettingsStore.h"
|
||||||
#include "git/GitManager.h"
|
#include "git/GitManager.h"
|
||||||
#include "sync/SyncManager.h"
|
#include "sync/SyncManager.h"
|
||||||
@@ -37,12 +38,14 @@ int main(int argc, char *argv[])
|
|||||||
GitManager gitManager;
|
GitManager gitManager;
|
||||||
SyncManager syncManager;
|
SyncManager syncManager;
|
||||||
VaultManager vaultManager(&settingsStore, &gitManager);
|
VaultManager vaultManager(&settingsStore, &gitManager);
|
||||||
|
DocumentManager documentManager;
|
||||||
|
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
engine.rootContext()->setContextProperty("settingsStore", &settingsStore);
|
engine.rootContext()->setContextProperty("settingsStore", &settingsStore);
|
||||||
engine.rootContext()->setContextProperty("gitManager", &gitManager);
|
engine.rootContext()->setContextProperty("gitManager", &gitManager);
|
||||||
engine.rootContext()->setContextProperty("syncManager", &syncManager);
|
engine.rootContext()->setContextProperty("syncManager", &syncManager);
|
||||||
engine.rootContext()->setContextProperty("vaultManager", &vaultManager);
|
engine.rootContext()->setContextProperty("vaultManager", &vaultManager);
|
||||||
|
engine.rootContext()->setContextProperty("documentManager", &documentManager);
|
||||||
|
|
||||||
WindowController windowController(
|
WindowController windowController(
|
||||||
&engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml"));
|
&engine, QStringLiteral("qrc:/qt/qml/Flora/qml/MainWindow.qml"));
|
||||||
|
|||||||
+11
-1
@@ -44,4 +44,14 @@ add_executable(flora_filestore_test filestore_test.cpp
|
|||||||
target_include_directories(flora_filestore_test PRIVATE ../src)
|
target_include_directories(flora_filestore_test PRIVATE ../src)
|
||||||
target_link_libraries(flora_filestore_test PRIVATE
|
target_link_libraries(flora_filestore_test PRIVATE
|
||||||
Qt6::Core)
|
Qt6::Core)
|
||||||
add_test(NAME flora_filestore_test COMMAND flora_filestore_test)
|
add_test(NAME flora_filestore_test COMMAND flora_filestore_test)
|
||||||
|
|
||||||
|
# Тест DocumentManager (вкладки, автосохранение, сохранение при закрытии).
|
||||||
|
add_executable(flora_docmgr_test documentmanager_test.cpp
|
||||||
|
../src/core/DocumentManager.cpp
|
||||||
|
../src/core/DocumentManager.h
|
||||||
|
)
|
||||||
|
target_include_directories(flora_docmgr_test PRIVATE ../src)
|
||||||
|
target_link_libraries(flora_docmgr_test PRIVATE
|
||||||
|
Qt6::Core)
|
||||||
|
add_test(NAME flora_docmgr_test COMMAND flora_docmgr_test)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Тест DocumentManager (Фаза 4): открытие вкладок, редактирование,
|
||||||
|
// сохранение, автосохранение по таймеру, закрытие, заголовки, dirty-флаги.
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#include "core/DocumentManager.h"
|
||||||
|
|
||||||
|
static int fail(const char *msg, const QString &detail = QString())
|
||||||
|
{
|
||||||
|
std::fprintf(stderr, "FAIL: %s %s\n", msg, qPrintable(detail));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
QCoreApplication app(argc, argv);
|
||||||
|
|
||||||
|
QTemporaryDir dir;
|
||||||
|
if (!dir.isValid())
|
||||||
|
return fail("temp dir");
|
||||||
|
|
||||||
|
const QString vault = dir.path();
|
||||||
|
QDir(vault).mkpath("sub");
|
||||||
|
|
||||||
|
// --- открытие ---
|
||||||
|
{
|
||||||
|
QFile f(vault + "/a.md");
|
||||||
|
f.open(QIODevice::WriteOnly);
|
||||||
|
f.write("# A\n");
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentManager dm;
|
||||||
|
dm.setAutosaveIntervalMs(300); // быстрый автосейв для теста
|
||||||
|
|
||||||
|
int idx = dm.openDocument(vault + "/a.md");
|
||||||
|
if (idx != 0)
|
||||||
|
return fail("openDocument вернул неверный индекс", QString::number(idx));
|
||||||
|
if (dm.openTabs().size() != 1)
|
||||||
|
return fail("вкладка не создана");
|
||||||
|
if (dm.contentAt(0) != "# A\n")
|
||||||
|
return fail("содержимое не прочитано");
|
||||||
|
if (dm.titleAt(0) != "a.md")
|
||||||
|
return fail("заголовок", dm.titleAt(0));
|
||||||
|
if (dm.isDirty(0))
|
||||||
|
return fail("свежий файл помечен изменённым");
|
||||||
|
|
||||||
|
// --- повторное открытие той же вкладки ---
|
||||||
|
if (dm.openDocument(vault + "/a.md") != 0)
|
||||||
|
return fail("повторное открытие создало новую вкладку");
|
||||||
|
|
||||||
|
// --- редактирование ---
|
||||||
|
dm.updateContent(0, "# A edited\n\nНовый текст\n");
|
||||||
|
if (!dm.isDirty(0))
|
||||||
|
return fail("правка не помечена");
|
||||||
|
if (dm.contentAt(0).isEmpty() || !dm.contentAt(0).contains("Новый текст"))
|
||||||
|
return fail("контент не обновился");
|
||||||
|
|
||||||
|
// --- сохранение ---
|
||||||
|
dm.saveDocument(0);
|
||||||
|
if (dm.isDirty(0))
|
||||||
|
return fail("после сохранения dirty остался");
|
||||||
|
{
|
||||||
|
QFile f(vault + "/a.md");
|
||||||
|
f.open(QIODevice::ReadOnly);
|
||||||
|
const QString s = QString::fromUtf8(f.readAll());
|
||||||
|
if (!s.contains("Новый текст"))
|
||||||
|
return fail("файл не записан");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- автосохранение по таймеру ---
|
||||||
|
dm.updateContent(0, "# A edited\n\nАвтосейв\n");
|
||||||
|
{
|
||||||
|
// ждём срабатывания таймера (300 мс) через событийный цикл
|
||||||
|
QTimer t;
|
||||||
|
QObject::connect(&t, &QTimer::timeout, &app, &QCoreApplication::quit);
|
||||||
|
t.setSingleShot(true);
|
||||||
|
t.start(700);
|
||||||
|
app.exec();
|
||||||
|
}
|
||||||
|
if (dm.isDirty(0))
|
||||||
|
return fail("автосохранение не сработало");
|
||||||
|
{
|
||||||
|
QFile f(vault + "/a.md");
|
||||||
|
f.open(QIODevice::ReadOnly);
|
||||||
|
if (!QString::fromUtf8(f.readAll()).contains("Автосейв"))
|
||||||
|
return fail("автосохранение не записало файл");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- второй документ и закрытие ---
|
||||||
|
{
|
||||||
|
QFile f(vault + "/sub/b.md");
|
||||||
|
f.open(QIODevice::WriteOnly);
|
||||||
|
f.write("b\n");
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
const int idxB = dm.openDocument(vault + "/sub/b.md");
|
||||||
|
if (idxB != 1)
|
||||||
|
return fail("второй документ", QString::number(idxB));
|
||||||
|
if (dm.openTabs().size() != 2)
|
||||||
|
return fail("вкладок должно быть 2");
|
||||||
|
|
||||||
|
dm.updateContent(idxB, "b изменён");
|
||||||
|
if (!dm.closeTab(1))
|
||||||
|
return fail("closeTab");
|
||||||
|
if (dm.openTabs().size() != 1)
|
||||||
|
return fail("вкладка не закрыта");
|
||||||
|
if (dm.isDirty(0))
|
||||||
|
return fail("активная вкладка испорчена после закрытия");
|
||||||
|
{
|
||||||
|
QFile f(vault + "/sub/b.md");
|
||||||
|
f.open(QIODevice::ReadOnly);
|
||||||
|
if (!QString::fromUtf8(f.readAll()).contains("изменён"))
|
||||||
|
return fail("закрытие не сохранило изменённый документ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- несуществующий файл ---
|
||||||
|
if (dm.openDocument(vault + "/нет.md") != -1)
|
||||||
|
return fail("открытие несуществующего файла");
|
||||||
|
|
||||||
|
// --- isTextForEditor ---
|
||||||
|
if (!dm.isTextForEditor("/x/a.md") || !dm.isTextForEditor("/x/a.txt")
|
||||||
|
|| !dm.isTextForEditor("/x/a.py") || dm.isTextForEditor("/x/a.png")
|
||||||
|
|| dm.isTextForEditor("/x/a.pdf"))
|
||||||
|
return fail("isTextForEditor");
|
||||||
|
|
||||||
|
std::printf("DOCUMENT MANAGER TEST OK\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user