From 784ad68758931ca54247127a936dbc3c1f437c09 Mon Sep 17 00:00:00 2001 From: eelke Date: Mon, 19 Oct 2026 18:16:25 +0200 Subject: [PATCH] Show UnsavedDocumentsDialog when closing window or quitting application so user can save and or discard docs more efficiently. --- pglab/CMakeLists.txt | 3 + pglab/ConnectionManagerWindow.cpp | 2 +- pglab/DatabaseWindow.cpp | 65 ++++++- pglab/DatabaseWindow.h | 3 + pglab/DatabaseWindow.ui | 6 + pglab/MasterController.cpp | 6 + pglab/MasterController.h | 6 + pglab/querytool/QueryTool.cpp | 166 ++++-------------- pglab/querytool/QueryTool.h | 25 +-- pglab/querytool/SqlDocument.cpp | 143 +++++++++++++++ pglab/querytool/SqlDocument.h | 60 +++++++ pglab/querytool/SqlDocumentRegistry.cpp | 27 +++ pglab/querytool/SqlDocumentRegistry.h | 27 +++ pglab/querytool/UnsavedDocumentsDialog.cpp | 112 ++++++++++++ pglab/querytool/UnsavedDocumentsDialog.h | 48 +++++ ...s-dialog-shared-docs-5c1e9a7d2b4f6e80.yaml | 9 + 16 files changed, 544 insertions(+), 164 deletions(-) create mode 100644 pglab/querytool/SqlDocument.cpp create mode 100644 pglab/querytool/SqlDocument.h create mode 100644 pglab/querytool/SqlDocumentRegistry.cpp create mode 100644 pglab/querytool/SqlDocumentRegistry.h create mode 100644 pglab/querytool/UnsavedDocumentsDialog.cpp create mode 100644 pglab/querytool/UnsavedDocumentsDialog.h create mode 100644 releasenotes/notes/unsaved-docs-dialog-shared-docs-5c1e9a7d2b4f6e80.yaml diff --git a/pglab/CMakeLists.txt b/pglab/CMakeLists.txt index bf2f110..8f3e85d 100644 --- a/pglab/CMakeLists.txt +++ b/pglab/CMakeLists.txt @@ -34,6 +34,9 @@ set(PGLAB_SOURCES querytool/QueryExplainModel.cpp querytool/QueryParamListController.cpp querytool/QueryTool.cpp + querytool/SqlDocument.cpp + querytool/SqlDocumentRegistry.cpp + querytool/UnsavedDocumentsDialog.cpp serverinspector/DatabasesPage.cpp serverinspector/RolesPage.cpp serverinspector/ServerInspector.cpp diff --git a/pglab/ConnectionManagerWindow.cpp b/pglab/ConnectionManagerWindow.cpp index 68a1a49..ddc0702 100644 --- a/pglab/ConnectionManagerWindow.cpp +++ b/pglab/ConnectionManagerWindow.cpp @@ -62,7 +62,7 @@ void ConnectionManagerWindow::on_actionQuit_application_triggered() { auto res = QMessageBox::question(this, "pglab", tr("Close all windows?"), QMessageBox::Yes, QMessageBox::No); - if (res == QMessageBox::Yes) { + if (res == QMessageBox::Yes && m_masterController->confirmQuit(this)) { QApplication::quit(); } } diff --git a/pglab/DatabaseWindow.cpp b/pglab/DatabaseWindow.cpp index 9d1fee8..66bbacd 100644 --- a/pglab/DatabaseWindow.cpp +++ b/pglab/DatabaseWindow.cpp @@ -20,10 +20,13 @@ #include #include #include +#include +#include #include "EditTableWidget.h" #include "CodeGenerator.h" #include "querytool/QueryTool.h" +#include "querytool/UnsavedDocumentsDialog.h" #include namespace pg = Pgsql; @@ -91,12 +94,18 @@ void DatabaseWindow::closeEvent(QCloseEvent *event) { for (int idx = 0; idx < ui->mainTabs->count(); ++idx) { - if (!canCloseTab(idx)) + auto mp = dynamic_cast(ui->mainTabs->widget(idx)); + if (mp && !dynamic_cast(mp) && !mp->CanClose(true)) { event->ignore(); return; } - } + } + + auto docs = documentsClosedWith(m_masterController->sqlDocumentRegistry().modifiedDocuments(), + [this](const QWidget *editor) { return editor->window() == this; }); + if (!confirmClose(this, docs)) + event->ignore(); } void DatabaseWindow::setConfig(const ConnectionConfig &config) @@ -199,14 +208,14 @@ void DatabaseWindow::openSqlFile(QString file_name) { if (!file_name.isEmpty()) { - auto *ct = new QueryTool(this, this); - if (ct->load(file_name)) + auto document = std::make_shared(); + if (document->load(file_name)) { + m_masterController->sqlDocumentRegistry().add(document); + auto *ct = new QueryTool(this, document, this); ct->addAction(ui->actionExecute_query); addPage(ct, ct->title()); } - else - delete ct; } } @@ -391,13 +400,53 @@ void DatabaseWindow::on_actionInspect_server_triggered() newServerInspectorPage(); } +void DatabaseWindow::openSharedSqlDocument(std::shared_ptr document) +{ + auto *ct = new QueryTool(this, std::move(document), this); + ct->addAction(ui->actionExecute_query); + addPage(ct, ct->title()); + ct->updateTitle(); +} + void DatabaseWindow::on_actionNew_Query_triggered() { - auto *ct = new QueryTool(this, this); + auto document = std::make_shared(); + m_masterController->sqlDocumentRegistry().add(document); + auto *ct = new QueryTool(this, document, this); ct->addAction(ui->actionExecute_query); addPage(ct, "new"); - ct->newdoc(); +} +void DatabaseWindow::on_actionOpen_in_other_window_triggered() +{ + auto ct = dynamic_cast(ui->mainTabs->currentWidget()); + if (!ct) + return; + + QList others; + for (auto *widget : QApplication::topLevelWidgets()) { + auto dw = qobject_cast(widget); + if (dw && dw != this && dw->isVisible()) + others.append(dw); + } + + if (others.isEmpty()) { + QMessageBox::information(this, "pglab", tr("No other window is open.")); + return; + } + + QStringList names; + for (int i = 0; i < others.size(); ++i) + names.append(QString("%1 (%2)").arg(others[i]->windowTitle()).arg(i + 1)); + + bool ok = false; + QString selected = QInputDialog::getItem(this, tr("Open in other window"), + tr("Select window:"), names, 0, false, &ok); + if (!ok) + return; + + auto target = others[names.indexOf(selected)]; + target->openSharedSqlDocument(ct->document()); } void DatabaseWindow::on_actionOpen_Query_triggered() diff --git a/pglab/DatabaseWindow.h b/pglab/DatabaseWindow.h index b86d917..0512f46 100644 --- a/pglab/DatabaseWindow.h +++ b/pglab/DatabaseWindow.h @@ -6,6 +6,7 @@ #include "OpenDatabase.h" #include "Pgsql_Connection.h" #include "IDatabaseWindow.h" +#include "querytool/SqlDocument.h" #include #include @@ -38,6 +39,7 @@ public: ~DatabaseWindow(); void setConfig(const ConnectionConfig &config); + void openSharedSqlDocument(std::shared_ptr document); std::shared_ptr getDatabase() { return m_database; } @@ -98,6 +100,7 @@ private slots: void on_actionInspect_server_triggered(); void on_actionNew_Query_triggered(); void on_actionOpen_Query_triggered(); + void on_actionOpen_in_other_window_triggered(); void on_actionSave_Query_triggered(); void on_actionPaste_lang_string_triggered(); void on_actionRefreshCatalog_triggered(); diff --git a/pglab/DatabaseWindow.ui b/pglab/DatabaseWindow.ui index 0db9810..8880462 100644 --- a/pglab/DatabaseWindow.ui +++ b/pglab/DatabaseWindow.ui @@ -43,6 +43,7 @@ + @@ -106,6 +107,11 @@ + + + Open in other window / Duplicate in window... + + diff --git a/pglab/MasterController.cpp b/pglab/MasterController.cpp index 5a25833..df9032f 100644 --- a/pglab/MasterController.cpp +++ b/pglab/MasterController.cpp @@ -1,5 +1,6 @@ #include "MasterController.h" #include "ConnectionController.h" +#include "querytool/UnsavedDocumentsDialog.h" #include "utils/PostgresqlUrlParser.h" #include #include @@ -59,4 +60,9 @@ SQLiteConnection& MasterController::userConfigDatabase() return m_userConfigDatabase; } +bool MasterController::confirmQuit(QWidget *parent) +{ + return confirmClose(parent, m_sqlDocumentRegistry.modifiedDocuments()); +} + diff --git a/pglab/MasterController.h b/pglab/MasterController.h index eeffc78..33709ce 100644 --- a/pglab/MasterController.h +++ b/pglab/MasterController.h @@ -7,6 +7,7 @@ #include #include #include "sqlite/SQLiteConnection.h" +#include "querytool/SqlDocumentRegistry.h" class ConnectionController; @@ -24,6 +25,10 @@ public: ConnectionController* connectionController(); SQLiteConnection& userConfigDatabase(); + + SqlDocumentRegistry& sqlDocumentRegistry() { return m_sqlDocumentRegistry; } + /// Asks once about all modified documents of all windows. Returns false when quitting must be aborted. + bool confirmQuit(QWidget *parent); signals: @@ -31,6 +36,7 @@ public slots: private: SQLiteConnection m_userConfigDatabase; + SqlDocumentRegistry m_sqlDocumentRegistry; ConnectionController* m_connectionController = nullptr; }; diff --git a/pglab/querytool/QueryTool.cpp b/pglab/querytool/QueryTool.cpp index f808335..7896a65 100644 --- a/pglab/querytool/QueryTool.cpp +++ b/pglab/querytool/QueryTool.cpp @@ -21,12 +21,16 @@ #include "util.h" #include "UserConfiguration.h" #include "IDatabaseWindow.h" +#include "UnsavedDocumentsDialog.h" +#include +#include -QueryTool::QueryTool(IDatabaseWindow *context, QWidget *parent) +QueryTool::QueryTool(IDatabaseWindow *context, std::shared_ptr document, QWidget *parent) : ManagedPage(parent) , m_context(context) , ui(new Ui::QueryTab) + , m_document(std::move(document)) , m_dbConnection() { ui->setupUi(this); @@ -40,13 +44,15 @@ QueryTool::QueryTool(IDatabaseWindow *context, QWidget *parent) ui->queryEdit->setFont(UserConfiguration::instance()->codeFont()); - highlighter = new SqlSyntaxHighlighter(ui->queryEdit->document()); + ui->queryEdit->setDocument(m_document->textDocument()); + m_document->addEditor(this); auto types = m_catalog->types(); if (types) { - highlighter->setTypes(*types); + m_document->highlighter()->setTypes(*types); } - connect(ui->queryEdit, &QPlainTextEdit::textChanged, this, &QueryTool::queryTextChanged); + connect(m_document.get(), &SqlDocument::modifiedChanged, this, &QueryTool::updateTitle); + connect(m_document.get(), &SqlDocument::fileNameChanged, this, &QueryTool::updateTitle); ui->queryEdit->setAcceptDrops(false); m_queryParamListController = new QueryParamListController(ui->paramTableView, m_context->openDatabase(), this); @@ -60,87 +66,42 @@ QueryTool::QueryTool(IDatabaseWindow *context, QWidget *parent) QueryTool::~QueryTool() { + // Detach the editor from the shared document, it may be destroyed together with this tool + auto detached = new QTextDocument(ui->queryEdit); + detached->setDocumentLayout(new QPlainTextDocumentLayout(detached)); + ui->queryEdit->setDocument(detached); + m_document->removeEditor(this); delete ui; } bool QueryTool::CanClose(bool prompt_user) { - bool can_close; - if (m_queryTextChanged) { - if (prompt_user) - can_close = continueWithoutSavingWarning(); - else - can_close = false; - } - else { - can_close = true; - } - return can_close; -} + if (!m_document->isModified()) + return true; -void QueryTool::newdoc() -{ - ui->queryEdit->clear(); - setFileName(tr("new")); - setQueryTextChanged(false); - m_new = true; -} + // The document stays open in another editor, so it is not closed with this tool + if (m_document->editors().size() > 1) + return true; -bool QueryTool::load(const QString &filename) -{ - bool result = false; - QFile file(filename); - if (file.open(QIODevice::ReadOnly)) { - QByteArray ba = file.readAll(); - auto toUtf16 = QStringDecoder(QStringDecoder::Utf8); - QString text = toUtf16(ba); - if (toUtf16.hasError()) { - file.reset(); - QTextStream stream(&file); - text = stream.readAll(); - } + if (!prompt_user) + return false; - ui->queryEdit->setPlainText(text); - setQueryTextChanged(false); - setFileName(filename); - m_new = false; - result = true; - } - return result; + return confirmClose(this, {m_document}); } bool QueryTool::save() { - bool result; - if (m_fileName.isEmpty() || m_new) { - result = saveAs(); - } - else { - result = saveSqlTo(m_fileName); - } - return result; + return m_document->save(this); } bool QueryTool::saveAs() { - bool result = false; - QString filename = promptUserForSaveSqlFilename(); - if (!filename.isEmpty()) { - result = saveSqlTo(filename); - if (result) { - setFileName(filename); - m_new = false; - } - } - return result; + return m_document->saveAs(this); } void QueryTool::saveCopyAs() { - QString filename = promptUserForSaveSqlFilename(); - if (!filename.isEmpty()) { - saveSqlTo(filename); - } + m_document->saveCopyAs(this); } void QueryTool::execute() @@ -245,81 +206,21 @@ void QueryTool::cancel() QString QueryTool::title() const { - QFileInfo fileInfo(m_fileName); + QFileInfo fileInfo(m_document->fileName()); QString fn(fileInfo.fileName()); return fn; } -void QueryTool::setFileName(const QString &filename) -{ - m_fileName = filename; - updateTitle(); -} - void QueryTool::updateTitle() { - QFileInfo fileInfo(m_fileName); + QFileInfo fileInfo(m_document->fileName()); QString title = fileInfo.fileName(); - if (m_queryTextChanged) { + if (m_document->isModified()) { title = "*" + title; } - m_context->setTitleForWidget(this, title, m_fileName); -} - -bool QueryTool::continueWithoutSavingWarning() -{ - QMessageBox msgBox; - msgBox.setIcon(QMessageBox::Warning); - msgBox.setText(QString("Save changes in document \"%1\" before closing?").arg(m_fileName)); - msgBox.setInformativeText("The changes will be lost when you choose Discard."); - msgBox.setStandardButtons(QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); - msgBox.setDefaultButton(QMessageBox::Cancel); - int ret = msgBox.exec(); - if (ret == QMessageBox::Save) { - if (!save()) { - // save failed or was a saveAs and was cancelled, don't close! - ret = QMessageBox::Cancel; - } - } - - return ret != QMessageBox::Cancel; -} - -bool QueryTool::saveSqlTo(const QString &filename) -{ - bool result = false; - QFileInfo fileinfo(filename); - - - QFile file(filename); - if (file.open(QIODevice::WriteOnly)) { - QTextStream stream(&file); - //stream.setCodec("utf-8"); - stream.setEncoding(QStringConverter::Utf8); - QString text = ui->queryEdit->toPlainText(); - stream << text; - - stream.flush(); - if (stream.status() == QTextStream::Ok) { - setQueryTextChanged(false); - result = true; - } - } - return result; -} - - -QString QueryTool::promptUserForSaveSqlFilename() -{ - QString home_dir = QStandardPaths::locate(QStandardPaths::HomeLocation, "", QStandardPaths::LocateDirectory); - return QFileDialog::getSaveFileName(this, tr("Save query"), home_dir, tr("SQL file (*.sql)")); -} - -void QueryTool::queryTextChanged() -{ - setQueryTextChanged(true); + m_context->setTitleForWidget(this, title, m_document->fileName()); } void QueryTool::connectionStateChanged() @@ -618,10 +519,3 @@ void QueryTool::exportData() exportDataToFilename(file_name); } -void QueryTool::setQueryTextChanged(bool queryTextChanged) -{ - if (m_queryTextChanged != queryTextChanged) { - m_queryTextChanged = queryTextChanged; - updateTitle(); - } -} diff --git a/pglab/querytool/QueryTool.h b/pglab/querytool/QueryTool.h index 1274642..b7547ab 100644 --- a/pglab/querytool/QueryTool.h +++ b/pglab/querytool/QueryTool.h @@ -6,6 +6,7 @@ #include "QueryExplainModel.h" #include "stopwatch.h" #include "tuplesresultwidget.h" +#include "SqlDocument.h" #include #include @@ -34,12 +35,9 @@ class PgDatabaseCatalog; class QueryTool : public ManagedPage { Q_OBJECT public: - QueryTool(IDatabaseWindow *context, QWidget *parent = nullptr); + QueryTool(IDatabaseWindow *context, std::shared_ptr document, QWidget *parent = nullptr); ~QueryTool() override; - void newdoc(); - bool load(const QString &filename); - void explain(bool analyze); bool CanClose(bool prompt_user); @@ -47,12 +45,12 @@ public: void generateCode(); void exportDataToFilename(const QString &filename); - QString fileName() const { return m_fileName; } - bool isChanged() const { return m_queryTextChanged; } - bool isNew() const { return m_new; } + QString fileName() const { return m_document->fileName(); } + std::shared_ptr document() const { return m_document; } void focusEditor(); QString title() const; + void updateTitle(); public slots: @@ -74,15 +72,12 @@ private: using ResultTabContainer = std::vector; IDatabaseWindow *m_context; Ui::QueryTab *ui; - SqlSyntaxHighlighter* highlighter; + std::shared_ptr m_document; ConnectionConfig m_config; StopWatch m_stopwatch; QueryParamListController *m_queryParamListController = nullptr; - bool m_new = true; - QString m_fileName; ///< use setFileName function to set - bool m_queryTextChanged = false; std::shared_ptr m_catalog; ASyncDBConnection m_dbConnection; @@ -90,11 +85,6 @@ private: std::unique_ptr explainModel; ResultTabContainer resultList; - void setFileName(const QString &filename); - bool continueWithoutSavingWarning(); - bool saveSqlTo(const QString &filename); - QString promptUserForSaveSqlFilename(); - void addLog(QString s); QString getCommand() const; @@ -102,15 +92,12 @@ private: void clearResult(); void markError(const Pgsql::ErrorDetails &details); - void updateTitle(); - void setQueryTextChanged(bool queryTextChanged); private slots: void explain_ready(ExplainRoot::SPtr explain); void query_ready(std::shared_ptr, qint64 elapsedms); - void queryTextChanged(); void connectionStateChanged(); void receiveNotice(Pgsql::ErrorDetails notice); diff --git a/pglab/querytool/SqlDocument.cpp b/pglab/querytool/SqlDocument.cpp new file mode 100644 index 0000000..cdd1bc2 --- /dev/null +++ b/pglab/querytool/SqlDocument.cpp @@ -0,0 +1,143 @@ +#include "SqlDocument.h" +#include "util/SqlSyntaxHighlighter.h" +#include +#include +#include +#include +#include +#include +#include +#include + +SqlDocument::SqlDocument(QObject *parent) + : QObject(parent) + , m_document(new QTextDocument(this)) + , m_highlighter(new SqlSyntaxHighlighter(m_document)) +{ + // QPlainTextEdit only accepts documents that use a QPlainTextDocumentLayout + m_document->setDocumentLayout(new QPlainTextDocumentLayout(m_document)); + connect(m_document, &QTextDocument::modificationChanged, this, &SqlDocument::modifiedChanged); + setFileName(tr("new")); +} + +SqlDocument::~SqlDocument() = default; + +bool SqlDocument::isModified() const +{ + return m_document->isModified(); +} + +void SqlDocument::setModified(bool modified) +{ + m_document->setModified(modified); +} + +void SqlDocument::clear() +{ + m_document->clear(); + setFileName(tr("new")); + m_new = true; + setModified(false); +} + +bool SqlDocument::load(const QString &filename) +{ + bool result = false; + QFile file(filename); + if (file.open(QIODevice::ReadOnly)) { + QByteArray ba = file.readAll(); + auto toUtf16 = QStringDecoder(QStringDecoder::Utf8); + QString text = toUtf16(ba); + if (toUtf16.hasError()) { + file.reset(); + QTextStream stream(&file); + text = stream.readAll(); + } + + m_document->setPlainText(text); + setModified(false); + setFileName(filename); + m_new = false; + result = true; + } + return result; +} + +bool SqlDocument::save(QWidget *parent) +{ + bool result; + if (m_fileName.isEmpty() || m_new) { + result = saveAs(parent); + } + else { + result = saveSqlTo(m_fileName); + } + return result; +} + +bool SqlDocument::saveAs(QWidget *parent) +{ + bool result = false; + QString filename = promptUserForSaveSqlFilename(parent); + if (!filename.isEmpty()) { + result = saveSqlTo(filename); + if (result) { + setFileName(filename); + m_new = false; + } + } + return result; +} + +bool SqlDocument::saveCopyAs(QWidget *parent) +{ + bool result = false; + QString filename = promptUserForSaveSqlFilename(parent); + if (!filename.isEmpty()) { + result = saveSqlTo(filename); + } + return result; +} + +void SqlDocument::addEditor(QWidget *editor) +{ + if (!m_editors.contains(editor)) + m_editors.append(editor); +} + +void SqlDocument::removeEditor(QWidget *editor) +{ + m_editors.removeAll(editor); +} + +void SqlDocument::setFileName(const QString &filename) +{ + m_fileName = filename; + emit fileNameChanged(); +} + +bool SqlDocument::saveSqlTo(const QString &filename) +{ + bool result = false; + + QFile file(filename); + if (file.open(QIODevice::WriteOnly)) { + QTextStream stream(&file); + stream.setEncoding(QStringConverter::Utf8); + QString text = m_document->toPlainText(); + stream << text; + + stream.flush(); + if (stream.status() == QTextStream::Ok) { + setModified(false); + result = true; + } + } + return result; +} + +QString SqlDocument::promptUserForSaveSqlFilename(QWidget *parent) +{ + QString home_dir = QStandardPaths::locate(QStandardPaths::HomeLocation, "", QStandardPaths::LocateDirectory); + return QFileDialog::getSaveFileName(parent, tr("Save query"), home_dir, tr("SQL file (*.sql)")); +} diff --git a/pglab/querytool/SqlDocument.h b/pglab/querytool/SqlDocument.h new file mode 100644 index 0000000..6974462 --- /dev/null +++ b/pglab/querytool/SqlDocument.h @@ -0,0 +1,60 @@ +#ifndef SQLDOCUMENT_H +#define SQLDOCUMENT_H + +#include +#include +#include + +class QTextDocument; +class QWidget; +class SqlSyntaxHighlighter; + +/** Owns the text of a SQL script together with its file state. + * Several query tools (editors) can show the same document, they all + * use the same QTextDocument. + */ +class SqlDocument : public QObject { + Q_OBJECT +public: + explicit SqlDocument(QObject *parent = nullptr); + ~SqlDocument() override; + + QTextDocument *textDocument() const { return m_document; } + SqlSyntaxHighlighter *highlighter() const { return m_highlighter; } + + QString fileName() const { return m_fileName; } + bool isNew() const { return m_new; } + bool isModified() const; + void setModified(bool modified); + + /// Empties the document and makes it a new unnamed document. + void clear(); + bool load(const QString &filename); + /// Save the document under its current name, a file save dialog will be shown if this is a new document + bool save(QWidget *parent); + /// Saves the document under a new name and continue editing the document under this new name. + bool saveAs(QWidget *parent); + /// Save the document under a new name but continue editing the document under its old name. + bool saveCopyAs(QWidget *parent); + + void addEditor(QWidget *editor); + void removeEditor(QWidget *editor); + const QList &editors() const { return m_editors; } + +signals: + void modifiedChanged(bool modified); + void fileNameChanged(); + +private: + QTextDocument *m_document; + SqlSyntaxHighlighter *m_highlighter; + QString m_fileName; + bool m_new = true; + QList m_editors; + + void setFileName(const QString &filename); + bool saveSqlTo(const QString &filename); + QString promptUserForSaveSqlFilename(QWidget *parent); +}; + +#endif // SQLDOCUMENT_H diff --git a/pglab/querytool/SqlDocumentRegistry.cpp b/pglab/querytool/SqlDocumentRegistry.cpp new file mode 100644 index 0000000..8d7364a --- /dev/null +++ b/pglab/querytool/SqlDocumentRegistry.cpp @@ -0,0 +1,27 @@ +#include "SqlDocumentRegistry.h" +#include "SqlDocument.h" + +void SqlDocumentRegistry::add(const std::shared_ptr &document) +{ + m_documents.push_back(document); +} + +QList> SqlDocumentRegistry::documents() const +{ + QList> result; + for (const auto &weak : m_documents) { + if (auto doc = weak.lock()) + result.append(doc); + } + return result; +} + +QList> SqlDocumentRegistry::modifiedDocuments() const +{ + QList> result; + for (auto &doc : documents()) { + if (doc->isModified()) + result.append(doc); + } + return result; +} diff --git a/pglab/querytool/SqlDocumentRegistry.h b/pglab/querytool/SqlDocumentRegistry.h new file mode 100644 index 0000000..b63f63b --- /dev/null +++ b/pglab/querytool/SqlDocumentRegistry.h @@ -0,0 +1,27 @@ +#ifndef SQLDOCUMENTREGISTRY_H +#define SQLDOCUMENTREGISTRY_H + +#include +#include +#include + +class SqlDocument; + +/** Keeps track of all SQL documents that are still in use, without + * extending their lifetime. A document lives as long as at least one + * query tool holds a shared pointer to it. + */ +class SqlDocumentRegistry { +public: + void add(const std::shared_ptr &document); + + /// All documents that are still alive. + QList> documents() const; + /// All documents that are alive and have unsaved changes. + QList> modifiedDocuments() const; + +private: + std::vector> m_documents; +}; + +#endif // SQLDOCUMENTREGISTRY_H diff --git a/pglab/querytool/UnsavedDocumentsDialog.cpp b/pglab/querytool/UnsavedDocumentsDialog.cpp new file mode 100644 index 0000000..f8d1050 --- /dev/null +++ b/pglab/querytool/UnsavedDocumentsDialog.cpp @@ -0,0 +1,112 @@ +#include "UnsavedDocumentsDialog.h" +#include +#include +#include +#include +#include +#include + +UnsavedDocumentsDialog::UnsavedDocumentsDialog(const QList> &docs, QWidget *parent) + : QDialog(parent) + , m_docs(docs) + , m_list(new QListWidget(this)) +{ + setWindowTitle(tr("Unsaved changes")); + + auto layout = new QVBoxLayout(this); + layout->addWidget(new QLabel(tr("The following documents have unsaved changes. Select the documents to save or discard:"), this)); + + for (const auto &doc : m_docs) { + QStringList windows; + for (const QWidget *editor : doc->editors()) { + QString title = editor->window()->windowTitle(); + if (!windows.contains(title)) + windows.append(title); + } + + auto item = new QListWidgetItem(QString("%1 (%2)").arg(doc->fileName(), windows.join(", ")), m_list); + item->setToolTip(doc->fileName()); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(Qt::Checked); + } + layout->addWidget(m_list); + + auto buttons = new QDialogButtonBox(this); + auto save_button = buttons->addButton(tr("Save selected"), QDialogButtonBox::AcceptRole); + auto discard_button = buttons->addButton(tr("Discard selected"), QDialogButtonBox::DestructiveRole); + buttons->addButton(QDialogButtonBox::Cancel); + layout->addWidget(buttons); + + connect(save_button, &QPushButton::clicked, this, [this]() { + m_choice = Choice::Save; + accept(); + }); + connect(discard_button, &QPushButton::clicked, this, [this]() { + m_choice = Choice::Discard; + accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +QList> UnsavedDocumentsDialog::checkedDocuments() const +{ + QList> result; + for (int i = 0; i < m_docs.size(); ++i) { + if (m_list->item(i)->checkState() == Qt::Checked) + result.append(m_docs[i]); + } + return result; +} + +QList> UnsavedDocumentsDialog::uncheckedDocuments() const +{ + QList> result; + for (int i = 0; i < m_docs.size(); ++i) { + if (m_list->item(i)->checkState() != Qt::Checked) + result.append(m_docs[i]); + } + return result; +} + +QList> documentsClosedWith(const QList> &docs, + const std::function &isClosing) +{ + QList> result; + for (const auto &doc : docs) { + const auto &editors = doc->editors(); + if (!doc->isModified() || editors.isEmpty()) + continue; + + bool all_closing = std::all_of(editors.begin(), editors.end(), + [&isClosing](const QWidget *editor) { return isClosing(editor); }); + if (all_closing) + result.append(doc); + } + return result; +} + +bool confirmClose(QWidget *parent, const QList> &docs) +{ + auto remaining = docs; + while (!remaining.isEmpty()) { + UnsavedDocumentsDialog dlg(remaining, parent); + if (dlg.exec() != QDialog::Accepted || dlg.choice() == UnsavedDocumentsDialog::Choice::Cancel) + return false; + + bool save = dlg.choice() == UnsavedDocumentsDialog::Choice::Save; + for (const auto &doc : dlg.checkedDocuments()) { + if (save) { + if (!doc->save(parent)) + return false; + } + else { + doc->setModified(false); + } + } + + // Show the dialog again for the documents the user did not select. + remaining = dlg.uncheckedDocuments(); + } + + return true; +} diff --git a/pglab/querytool/UnsavedDocumentsDialog.h b/pglab/querytool/UnsavedDocumentsDialog.h new file mode 100644 index 0000000..c499876 --- /dev/null +++ b/pglab/querytool/UnsavedDocumentsDialog.h @@ -0,0 +1,48 @@ +#ifndef UNSAVEDDOCUMENTSDIALOG_H +#define UNSAVEDDOCUMENTSDIALOG_H + +#include "SqlDocument.h" +#include +#include +#include +#include + +class QListWidget; + +/** Lists modified documents with a checkbox per document. The user picks + * whether the checked documents should be saved or discarded. + */ +class UnsavedDocumentsDialog : public QDialog { + Q_OBJECT +public: + enum class Choice { + Cancel, + Save, + Discard + }; + + UnsavedDocumentsDialog(const QList> &docs, QWidget *parent = nullptr); + + Choice choice() const { return m_choice; } + QList> checkedDocuments() const; + QList> uncheckedDocuments() const; + +private: + QList> m_docs; + QListWidget *m_list; + Choice m_choice = Choice::Cancel; +}; + +/** Selects the documents that are closed when every editor for which + * isClosing returns true is closed. Only modified documents are returned. + */ +QList> documentsClosedWith(const QList> &docs, + const std::function &isClosing); + +/** Asks the user what to do with the modified documents. Returns true when + * the close may continue, i.e. nothing was cancelled, no save failed and + * every listed document was checked. + */ +bool confirmClose(QWidget *parent, const QList> &docs); + +#endif // UNSAVEDDOCUMENTSDIALOG_H diff --git a/releasenotes/notes/unsaved-docs-dialog-shared-docs-5c1e9a7d2b4f6e80.yaml b/releasenotes/notes/unsaved-docs-dialog-shared-docs-5c1e9a7d2b4f6e80.yaml new file mode 100644 index 0000000..9074dbc --- /dev/null +++ b/releasenotes/notes/unsaved-docs-dialog-shared-docs-5c1e9a7d2b4f6e80.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Closing a window or quitting the application now shows a single "Unsaved changes" dialog listing all modified SQL documents. Select the documents to save or discard; after the action has completed the dialog is shown again for the remaining documents, until all are handled or you cancel. + - | + A SQL document can be opened in another window with its own connection via the new "Open in other window / Duplicate in window..." action in the File menu. Both windows share the same text, and the document is only prompted for when its last editor closes. +fixes: + - | + Quitting the application from the connection manager no longer discards unsaved SQL changes silently.