File indexing completed on 2024-04-28 05:49:06

0001 /*  This file is part of the Kate project.
0002  *
0003  *  SPDX-FileCopyrightText: 2012 Christoph Cullmann <cullmann@kde.org>
0004  *
0005  *  SPDX-License-Identifier: LGPL-2.0-or-later
0006  */
0007 
0008 #include "kateproject.h"
0009 #include "kateprojectitem.h"
0010 #include "kateprojectplugin.h"
0011 #include "kateprojectworker.h"
0012 
0013 #include <KIO/CopyJob>
0014 #include <KJobWidgets>
0015 #include <KLocalizedString>
0016 #include <QTextDocument>
0017 #include <ktexteditor/document.h>
0018 
0019 #include <json_utils.h>
0020 
0021 #include <QApplication>
0022 #include <QDir>
0023 #include <QFile>
0024 #include <QFileInfo>
0025 #include <QJsonArray>
0026 #include <QJsonDocument>
0027 #include <QJsonObject>
0028 #include <QJsonParseError>
0029 #include <QMimeData>
0030 #include <QPlainTextDocumentLayout>
0031 #include <utility>
0032 
0033 bool KateProjectModel::dropMimeData(const QMimeData *data, Qt::DropAction action, int row, int column, const QModelIndex &parent)
0034 {
0035     if (!canDropMimeData(data, action, row, column, parent)) {
0036         return false;
0037     }
0038 
0039     const auto index = this->index(row, column, parent);
0040     const auto type = (KateProjectItem::Type)index.data(KateProjectItem::TypeRole).toInt();
0041     const auto parentType = (KateProjectItem::Type)parent.data(KateProjectItem::TypeRole).toInt();
0042     QString pathToCopyTo;
0043     if (!index.isValid() && parent.isValid() && parentType == KateProjectItem::Directory) {
0044         pathToCopyTo = parent.data(Qt::UserRole).toString();
0045     } else if (index.isValid() && type == KateProjectItem::File) {
0046         if (index.parent().isValid()) {
0047             pathToCopyTo = index.parent().data(Qt::UserRole).toString();
0048         } else {
0049             pathToCopyTo = m_project->baseDir();
0050         }
0051     } else if (!index.isValid() && !parent.isValid()) {
0052         pathToCopyTo = m_project->baseDir();
0053     }
0054 
0055     const QDir d = pathToCopyTo;
0056     if (!d.exists()) {
0057         return false;
0058     }
0059 
0060     const auto urls = data->urls();
0061     const QString destDir = d.absolutePath();
0062     const QUrl dest = QUrl::fromLocalFile(destDir);
0063     QPointer<KIO::CopyJob> job = KIO::copy(urls, dest);
0064     KJobWidgets::setWindow(job, QApplication::activeWindow());
0065     connect(job, &KIO::Job::finished, this, [this, job, destDir] {
0066         if (!job || job->error() != 0 || !m_project)
0067             return;
0068 
0069         bool needsReload = false;
0070         QStandardItem *item = invisibleRootItem();
0071         if (destDir != m_project->baseDir()) {
0072             const auto indexes = match(this->index(0, 0), Qt::UserRole, destDir, 1, Qt::MatchStartsWith);
0073             if (indexes.empty()) {
0074                 needsReload = true;
0075             } else {
0076                 item = itemFromIndex(indexes.constFirst());
0077             }
0078         }
0079 
0080         const auto urls = job->srcUrls();
0081         if (!needsReload) {
0082             for (const auto &url : urls) {
0083                 const QString newFile = destDir + QStringLiteral("/") + url.fileName();
0084                 const QFileInfo fi(newFile);
0085                 if (fi.exists() && fi.isFile()) {
0086                     KateProjectItem *i = new KateProjectItem(KateProjectItem::File, url.fileName());
0087                     item->appendRow(i);
0088                     m_project->addFile(newFile, i);
0089                 } else {
0090                     // not a file? Just do a reload of the project on finish
0091                     needsReload = true;
0092                     break;
0093                 }
0094             }
0095         }
0096         if (needsReload && m_project) {
0097             QMetaObject::invokeMethod(
0098                 this,
0099                 [this] {
0100                     m_project->reload(true);
0101                 },
0102                 Qt::QueuedConnection);
0103         }
0104     });
0105     job->start();
0106 
0107     return true;
0108 }
0109 
0110 bool KateProjectModel::canDropMimeData(const QMimeData *data, Qt::DropAction action, int, int, const QModelIndex &) const
0111 {
0112     return data && data->hasUrls() && action == Qt::CopyAction;
0113 }
0114 
0115 KateProject::KateProject(QThreadPool &threadPool, KateProjectPlugin *plugin, const QString &fileName)
0116     : m_threadPool(threadPool)
0117     , m_plugin(plugin)
0118     , m_fileBacked(true)
0119     , m_fileName(QFileInfo(fileName).absoluteFilePath())
0120     , m_baseDir(QFileInfo(fileName).absolutePath())
0121 {
0122     // ensure we get notified for project file changes
0123     connect(&m_plugin->fileWatcher(), &QFileSystemWatcher::fileChanged, this, &KateProject::slotFileChanged);
0124     m_plugin->fileWatcher().addPath(m_fileName);
0125 
0126     m_model.m_project = this;
0127 
0128     // try to load the project map from our file, will start worker thread, too
0129     reload();
0130 }
0131 
0132 KateProject::KateProject(QThreadPool &threadPool, KateProjectPlugin *plugin, const QVariantMap &globalProject, const QString &directory)
0133     : m_threadPool(threadPool)
0134     , m_plugin(plugin)
0135     , m_fileBacked(false)
0136     , m_fileName(QDir(QDir(directory).absolutePath()).filePath(QStringLiteral(".kateproject")))
0137     , m_baseDir(QDir(directory).absolutePath())
0138     , m_globalProject(globalProject)
0139 {
0140     m_model.m_project = this;
0141     // try to load the project map, will start worker thread, too
0142     load(globalProject);
0143 }
0144 
0145 KateProject::~KateProject()
0146 {
0147     saveNotesDocument();
0148 
0149     // stop watching if we have some real project file
0150     if (m_fileBacked && !m_fileName.isEmpty()) {
0151         m_plugin->fileWatcher().removePath(m_fileName);
0152     }
0153 }
0154 
0155 bool KateProject::reload(bool force)
0156 {
0157     const QVariantMap map = readProjectFile();
0158     if (!map.isEmpty()) {
0159         m_globalProject = map;
0160     }
0161 
0162     return load(m_globalProject, force);
0163 }
0164 
0165 void KateProject::renameFile(const QString &newName, const QString &oldName)
0166 {
0167     auto it = m_file2Item->find(oldName);
0168     if (it == m_file2Item->end()) {
0169         qWarning() << "renameFile() File not found, new: " << newName << "old: " << oldName;
0170         return;
0171     }
0172     (*m_file2Item)[newName] = it.value();
0173     m_file2Item->erase(it);
0174 }
0175 
0176 void KateProject::removeFile(const QString &file)
0177 {
0178     auto it = m_file2Item->find(file);
0179     if (it == m_file2Item->end()) {
0180         qWarning() << "removeFile() File not found: " << file;
0181         return;
0182     }
0183     m_file2Item->erase(it);
0184 }
0185 
0186 /**
0187  * Read a JSON document from file.
0188  *
0189  * In case of an error, the returned object verifies isNull() is true.
0190  */
0191 QJsonDocument KateProject::readJSONFile(const QString &fileName) const
0192 {
0193     /**
0194      * keep each project file last modification time to warn the user only once per malformed file.
0195      */
0196     static QHash<QString, QDateTime> lastModifiedTimes;
0197 
0198     if (fileName.isEmpty()) {
0199         return QJsonDocument();
0200     }
0201 
0202     QFile file(fileName);
0203     if (!file.exists() || !file.open(QFile::ReadOnly)) {
0204         return QJsonDocument();
0205     }
0206 
0207     /**
0208      * parse the whole file, bail out again on error!
0209      */
0210     const QByteArray jsonData = file.readAll();
0211     QJsonParseError parseError{};
0212     QJsonDocument document(QJsonDocument::fromJson(jsonData, &parseError));
0213 
0214     if (parseError.error != QJsonParseError::NoError) {
0215         QDateTime lastModified = QFileInfo(fileName).lastModified();
0216         if (lastModified > lastModifiedTimes.value(fileName, QDateTime())) {
0217             lastModifiedTimes[fileName] = lastModified;
0218             m_plugin->sendMessage(i18n("Malformed JSON file '%1': %2", fileName, parseError.errorString()), true);
0219         }
0220         return QJsonDocument();
0221     }
0222 
0223     return document;
0224 }
0225 
0226 QVariantMap KateProject::readProjectFile() const
0227 {
0228     // not file back => will not work
0229     if (!m_fileBacked) {
0230         return QVariantMap();
0231     }
0232 
0233     // bail out on error
0234     QJsonDocument project(readJSONFile(m_fileName));
0235     if (project.isNull()) {
0236         return QVariantMap();
0237     }
0238 
0239     /**
0240      * convenience; align with auto-generated projects
0241      * generate 'name' and 'files' if not specified explicitly,
0242      * so those parts need not be given if only wishes to specify additional
0243      * project configuration (e.g. build, ctags)
0244      */
0245     if (project.isObject()) {
0246         auto dir = QFileInfo(m_fileName).dir();
0247         auto object = project.object();
0248 
0249         // if there are local settings (.kateproject.local), override values
0250         {
0251             const auto localSettings = readJSONFile(projectLocalFileName(QStringLiteral("local")));
0252             if (!localSettings.isNull() && localSettings.isObject()) {
0253                 object = json::merge(object, localSettings.object());
0254             }
0255         }
0256 
0257         auto name = object[QStringLiteral("name")];
0258         if (name.isUndefined() || name.isNull()) {
0259             name = dir.dirName();
0260         }
0261         auto files = object[QStringLiteral("files")];
0262         if (files.isUndefined() || files.isNull()) {
0263             // support all we can, could try to detect,
0264             // but it will be sorted out upon loading anyway
0265             QJsonArray afiles;
0266             for (const auto &t : {QStringLiteral("git"), QStringLiteral("hg"), QStringLiteral("svn"), QStringLiteral("darcs")}) {
0267                 afiles.push_back(QJsonObject{{t, true}});
0268             }
0269             files = afiles;
0270         }
0271         project.setObject(object);
0272     }
0273 
0274     return project.toVariant().toMap();
0275 }
0276 
0277 bool KateProject::load(const QVariantMap &globalProject, bool force)
0278 {
0279     /**
0280      * no name, bad => bail out
0281      */
0282     if (globalProject[QStringLiteral("name")].toString().isEmpty()) {
0283         return false;
0284     }
0285 
0286     /**
0287      * support out-of-source project files
0288      * ensure we handle relative paths properly => relative to the potential invented .kateproject file name
0289      */
0290     const auto baseDir = globalProject[QStringLiteral("directory")].toString();
0291     if (!baseDir.isEmpty()) {
0292         m_baseDir = QFileInfo(QFileInfo(m_fileName).dir(), baseDir).absoluteFilePath();
0293     }
0294 
0295     /**
0296      * anything changed?
0297      * else be done without forced reload!
0298      */
0299     if (!force && (m_projectMap == globalProject)) {
0300         return true;
0301     }
0302 
0303     /**
0304      * setup global attributes in this object
0305      */
0306     m_projectMap = globalProject;
0307 
0308     // emit that we changed stuff
0309     Q_EMIT projectMapChanged();
0310 
0311     // trigger loading of project in background thread
0312     QString indexDir;
0313     if (m_plugin->getIndexEnabled()) {
0314         indexDir = m_plugin->getIndexDirectory().toLocalFile();
0315         // if empty, use regular tempdir
0316         if (indexDir.isEmpty()) {
0317             indexDir = QDir::tempPath();
0318         }
0319     }
0320 
0321     auto column = m_model.invisibleRootItem()->takeColumn(0);
0322     m_untrackedDocumentsRoot = nullptr;
0323     m_file2Item.reset();
0324     auto deleter = QRunnable::create([column = std::move(column)] {
0325         qDeleteAll(column);
0326     });
0327     m_threadPool.start(deleter);
0328 
0329     // let's run the stuff in our own thread pool
0330     // do manual queued connect, as only run() is done in extra thread, object stays in this one
0331     auto w = new KateProjectWorker(m_baseDir, indexDir, m_projectMap, force);
0332     connect(w, &KateProjectWorker::loadDone, this, &KateProject::loadProjectDone, Qt::QueuedConnection);
0333     connect(w, &KateProjectWorker::loadIndexDone, this, &KateProject::loadIndexDone, Qt::QueuedConnection);
0334     m_threadPool.start(w);
0335 
0336     // we are done here
0337     return true;
0338 }
0339 
0340 void KateProject::loadProjectDone(const KateProjectSharedQStandardItem &topLevel, KateProjectSharedQHashStringItem file2Item)
0341 {
0342     m_model.clear();
0343     m_model.invisibleRootItem()->appendColumn(topLevel->takeColumn(0));
0344     m_untrackedDocumentsRoot = nullptr;
0345     m_file2Item = std::move(file2Item);
0346 
0347     /**
0348      * readd the documents that are open atm
0349      */
0350     for (auto i = m_documents.constBegin(); i != m_documents.constEnd(); i++) {
0351         registerDocument(i.key());
0352     }
0353 
0354     Q_EMIT modelChanged();
0355 }
0356 
0357 void KateProject::loadIndexDone(KateProjectSharedProjectIndex projectIndex)
0358 {
0359     /**
0360      * move to our project
0361      */
0362     m_projectIndex = std::move(projectIndex);
0363 
0364     /**
0365      * notify external world that data is available
0366      */
0367     Q_EMIT indexChanged();
0368 }
0369 
0370 QString KateProject::projectLocalFileName(const QString &suffix) const
0371 {
0372     /**
0373      * nothing on empty file names for project
0374      * should not happen
0375      */
0376     if (m_baseDir.isEmpty() || suffix.isEmpty()) {
0377         return QString();
0378     }
0379 
0380     /**
0381      * compute full file name
0382      */
0383     return QDir(m_baseDir).filePath(QStringLiteral(".kateproject.") + suffix);
0384 }
0385 
0386 QTextDocument *KateProject::notesDocument()
0387 {
0388     /**
0389      * already there?
0390      */
0391     if (m_notesDocument) {
0392         return m_notesDocument;
0393     }
0394 
0395     /**
0396      * else create it
0397      */
0398     m_notesDocument = new QTextDocument(this);
0399     m_notesDocument->setDocumentLayout(new QPlainTextDocumentLayout(m_notesDocument));
0400 
0401     /**
0402      * get file name
0403      */
0404     const QString notesFileName = projectLocalFileName(QStringLiteral("notes"));
0405     if (notesFileName.isEmpty()) {
0406         return m_notesDocument;
0407     }
0408 
0409     /**
0410      * and load text if possible
0411      */
0412     QFile inFile(notesFileName);
0413     if (inFile.open(QIODevice::ReadOnly)) {
0414         QTextStream inStream(&inFile);
0415         m_notesDocument->setPlainText(inStream.readAll());
0416     }
0417 
0418     /**
0419      * and be done
0420      */
0421     return m_notesDocument;
0422 }
0423 
0424 void KateProject::saveNotesDocument()
0425 {
0426     /**
0427      * no notes document, nothing to do
0428      */
0429     if (!m_notesDocument) {
0430         return;
0431     }
0432 
0433     /**
0434      * get content & filename
0435      */
0436     const QString content = m_notesDocument->toPlainText();
0437     const QString notesFileName = projectLocalFileName(QStringLiteral("notes"));
0438     if (notesFileName.isEmpty()) {
0439         return;
0440     }
0441 
0442     /**
0443      * no content => unlink file, if there
0444      */
0445     if (content.isEmpty()) {
0446         if (QFile::exists(notesFileName)) {
0447             QFile::remove(notesFileName);
0448         }
0449         return;
0450     }
0451 
0452     /**
0453      * else: save content to file
0454      */
0455     QFile outFile(projectLocalFileName(QStringLiteral("notes")));
0456     if (outFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
0457         QTextStream outStream(&outFile);
0458         outStream << content;
0459     }
0460 }
0461 
0462 void KateProject::slotModifiedChanged(KTextEditor::Document *document)
0463 {
0464     KateProjectItem *item = itemForFile(m_documents.value(document));
0465 
0466     if (!item) {
0467         return;
0468     }
0469 
0470     item->slotModifiedChanged(document);
0471 }
0472 
0473 void KateProject::slotModifiedOnDisk(KTextEditor::Document *document, bool isModified, KTextEditor::Document::ModifiedOnDiskReason reason)
0474 {
0475     KateProjectItem *item = itemForFile(m_documents.value(document));
0476 
0477     if (!item) {
0478         return;
0479     }
0480 
0481     item->slotModifiedOnDisk(document, isModified, reason);
0482 }
0483 
0484 void KateProject::registerDocument(KTextEditor::Document *document)
0485 {
0486     // remember the document, if not already there
0487     if (!m_documents.contains(document)) {
0488         m_documents[document] = document->url().toLocalFile();
0489     }
0490 
0491     // try to get item for the document
0492     KateProjectItem *item = itemForFile(document->url().toLocalFile());
0493 
0494     // if we got one, we are done, else create a dummy!
0495     // clang-format off
0496     if (item) {
0497         disconnect(document, &KTextEditor::Document::modifiedChanged, this, &KateProject::slotModifiedChanged);
0498         disconnect(document, &KTextEditor::Document::modifiedOnDisk, this, &KateProject::slotModifiedOnDisk);
0499         item->slotModifiedChanged(document);
0500 
0501         /*FIXME    item->slotModifiedOnDisk(document,document->isModified(),qobject_cast<KTextEditor::ModificationInterface*>(document)->modifiedOnDisk());
0502          * FIXME*/
0503 
0504         connect(document, &KTextEditor::Document::modifiedChanged, this, &KateProject::slotModifiedChanged);
0505         connect(document, &KTextEditor::Document::modifiedOnDisk, this, &KateProject::slotModifiedOnDisk);
0506 
0507         return;
0508     }
0509     // clang-format on
0510 
0511     registerUntrackedDocument(document);
0512 }
0513 
0514 void KateProject::registerUntrackedDocument(KTextEditor::Document *document)
0515 {
0516     // perhaps create the parent item
0517     if (!m_untrackedDocumentsRoot) {
0518         m_untrackedDocumentsRoot = new KateProjectItem(KateProjectItem::Directory, i18n("<untracked>"));
0519         m_model.insertRow(0, m_untrackedDocumentsRoot);
0520     }
0521 
0522     // create document item
0523     QFileInfo fileInfo(document->url().toLocalFile());
0524     KateProjectItem *fileItem = new KateProjectItem(KateProjectItem::File, fileInfo.fileName());
0525     fileItem->slotModifiedChanged(document);
0526     connect(document, &KTextEditor::Document::modifiedChanged, this, &KateProject::slotModifiedChanged);
0527     connect(document, &KTextEditor::Document::modifiedOnDisk, this, &KateProject::slotModifiedOnDisk);
0528 
0529     bool inserted = false;
0530     for (int i = 0; i < m_untrackedDocumentsRoot->rowCount(); ++i) {
0531         if (m_untrackedDocumentsRoot->child(i)->data(Qt::UserRole).toString() > document->url().toLocalFile()) {
0532             m_untrackedDocumentsRoot->insertRow(i, fileItem);
0533             inserted = true;
0534             break;
0535         }
0536     }
0537     if (!inserted) {
0538         m_untrackedDocumentsRoot->appendRow(fileItem);
0539     }
0540 
0541     fileItem->setData(document->url().toLocalFile(), Qt::UserRole);
0542     fileItem->setData(QVariant(true), Qt::UserRole + 3);
0543 
0544     if (!m_file2Item) {
0545         m_file2Item = KateProjectSharedQHashStringItem(new QHash<QString, KateProjectItem *>());
0546     }
0547     (*m_file2Item)[document->url().toLocalFile()] = fileItem;
0548 }
0549 
0550 void KateProject::unregisterDocument(KTextEditor::Document *document)
0551 {
0552     if (!m_documents.contains(document)) {
0553         return;
0554     }
0555 
0556     // ignore further updates but clear state once
0557     disconnect(document, &KTextEditor::Document::modifiedChanged, this, &KateProject::slotModifiedChanged);
0558     const QString &file = m_documents.value(document);
0559     KateProjectItem *item = static_cast<KateProjectItem *>(itemForFile(file));
0560     if (item) {
0561         item->slotModifiedChanged(nullptr);
0562     }
0563 
0564     if (m_untrackedDocumentsRoot) {
0565         if (item && item->data(Qt::UserRole + 3).toBool()) {
0566             unregisterUntrackedItem(item);
0567             m_file2Item->remove(file);
0568         }
0569     }
0570 
0571     m_documents.remove(document);
0572 }
0573 
0574 void KateProject::unregisterUntrackedItem(const KateProjectItem *item)
0575 {
0576     for (int i = 0; i < m_untrackedDocumentsRoot->rowCount(); ++i) {
0577         if (m_untrackedDocumentsRoot->child(i) == item) {
0578             m_untrackedDocumentsRoot->removeRow(i);
0579             break;
0580         }
0581     }
0582 
0583     if (m_untrackedDocumentsRoot->rowCount() < 1) {
0584         m_model.removeRow(0);
0585         m_untrackedDocumentsRoot = nullptr;
0586     }
0587 }
0588 
0589 void KateProject::slotFileChanged(const QString &file)
0590 {
0591     if (file == m_fileName) {
0592         reload();
0593     }
0594 }
0595 
0596 #include "moc_kateproject.cpp"