File indexing completed on 2024-05-05 04:40:09

0001 /*
0002     SPDX-FileCopyrightText: 2009 Andreas Pakulat <apaku@gmx.de>
0003 
0004     SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "openwithplugin.h"
0008 
0009 #include <QAction>
0010 #include <QApplication>
0011 #include <QMenu>
0012 #include <QMimeDatabase>
0013 #include <QMimeType>
0014 #include <QVariantList>
0015 
0016 #include <KSharedConfig>
0017 #include <KConfigGroup>
0018 #include <KLocalizedString>
0019 #include <KMessageBox>
0020 #include <KMessageBox_KDevCompat>
0021 #include <KApplicationTrader>
0022 #include <KParts/MainWindow>
0023 #include <KParts/PartLoader>
0024 #include <KPluginFactory>
0025 #include <KOpenWithDialog>
0026 #include <KIO/ApplicationLauncherJob>
0027 #include <kio_version.h>
0028 #if KIO_VERSION < QT_VERSION_CHECK(5, 98, 0)
0029 #include <KIO/JobUiDelegate>
0030 #else
0031 #include <KIO/JobUiDelegateFactory>
0032 #endif
0033 
0034 #include <interfaces/contextmenuextension.h>
0035 #include <interfaces/context.h>
0036 #include <project/projectmodel.h>
0037 #include <util/path.h>
0038 
0039 #include <interfaces/icore.h>
0040 #include <interfaces/iuicontroller.h>
0041 #include <interfaces/idocumentcontroller.h>
0042 
0043 #include <utility>
0044 
0045 using namespace KDevelop;
0046 
0047 K_PLUGIN_FACTORY_WITH_JSON(KDevOpenWithFactory, "kdevopenwith.json", registerPlugin<OpenWithPlugin>();)
0048 
0049 namespace {
0050 constexpr QLatin1String partIdConfigEntryValuePrefix{"PART-ID:", 8};
0051 
0052 bool isTextPart(const QString& pluginId)
0053 {
0054     return pluginId == QLatin1String("katepart");
0055 }
0056 
0057 bool isDirectory(const QString& mimeType)
0058 {
0059     return mimeType == QLatin1String("inode/directory");
0060 }
0061 
0062 KConfigGroup defaultsConfig()
0063 {
0064     return KSharedConfig::openConfig()->group("Open With Defaults");
0065 }
0066 
0067 bool sortActions(QAction* left, QAction* right)
0068 {
0069     return left->text() < right->text();
0070 }
0071 
0072 QList<QAction*> sortedActions(QList<QAction*> actions, int sortOffset)
0073 {
0074     if (!actions.isEmpty()) {
0075         Q_ASSERT(actions.size() >= sortOffset);
0076         std::sort(actions.begin() + sortOffset, actions.end(), sortActions);
0077     }
0078     return actions;
0079 }
0080 
0081 QAction* createAction(const QString& name, const QString& iconName, QWidget* parent, bool isDefault)
0082 {
0083     auto* action = new QAction(QIcon::fromTheme(iconName), name, parent);
0084     if (isDefault) {
0085         QFont font = action->font();
0086         font.setBold(true);
0087         action->setFont(font);
0088     }
0089     return action;
0090 }
0091 } // unnamed namespace
0092 
0093 using OpenWithUtils::FileOpener;
0094 
0095 FileOpener::FileOpener(const KService::Ptr& service)
0096     : FileOpener(false, service->storageId())
0097 {
0098     m_service = service;
0099     Q_ASSERT(service->isApplication());
0100 }
0101 
0102 FileOpener FileOpener::fromPartId(const QString& partId)
0103 {
0104     return FileOpener{true, partId};
0105 }
0106 
0107 FileOpener FileOpener::fromConfigEntryValue(const QString& value)
0108 {
0109     if (value.startsWith(partIdConfigEntryValuePrefix)) {
0110         // TODO: can the part ID be validated efficiently here? A matching plugin
0111         // should be available and support the MIME type key of the config entry.
0112         return FileOpener::fromPartId(value.mid(partIdConfigEntryValuePrefix.size()));
0113     }
0114 
0115     if (!value.isEmpty()) {
0116         auto service = KService::serviceByStorageId(value);
0117         if (service && service->isApplication()) {
0118             FileOpener opener{false, value};
0119             opener.m_service = std::move(service);
0120             return opener;
0121         }
0122     }
0123 
0124     return FileOpener{false, QString()};
0125 }
0126 
0127 QString FileOpener::toConfigEntryValue() const
0128 {
0129     Q_ASSERT(isValid());
0130     if (m_isPart) {
0131         return partIdConfigEntryValuePrefix + m_id;
0132     }
0133     return m_id;
0134 }
0135 
0136 bool FileOpener::isValid() const
0137 {
0138     return !m_id.isEmpty();
0139 }
0140 
0141 bool FileOpener::isPart() const
0142 {
0143     Q_ASSERT(isValid());
0144     return m_isPart;
0145 }
0146 
0147 const QString& FileOpener::id() const
0148 {
0149     Q_ASSERT(isValid());
0150     return m_id;
0151 }
0152 
0153 const KService::Ptr& FileOpener::service() const
0154 {
0155     Q_ASSERT(isValid());
0156     Q_ASSERT(!isPart());
0157     Q_ASSERT(m_service);
0158     return m_service;
0159 }
0160 
0161 FileOpener::FileOpener(bool isPart, const QString& id)
0162     : m_isPart{isPart}
0163     , m_id{id}
0164 {
0165 }
0166 
0167 bool OpenWithUtils::operator==(const FileOpener& a, const FileOpener& b)
0168 {
0169     Q_ASSERT(a.isValid());
0170     Q_ASSERT(b.isValid());
0171     return a.isPart() == b.isPart() && a.id() == b.id();
0172 }
0173 
0174 bool OpenWithPlugin::canOpenDefault() const
0175 {
0176     if (!m_defaultOpener.isValid() && isDirectory(m_mimeType)) {
0177         // potentially happens in non-kde environments apparently, see https://git.reviewboard.kde.org/r/122373
0178         return KApplicationTrader::preferredService(m_mimeType);
0179     } else {
0180         return true;
0181     }
0182 }
0183 
0184 OpenWithPlugin::OpenWithPlugin ( QObject* parent, const QVariantList& )
0185     : IPlugin ( QStringLiteral("kdevopenwith"), parent )
0186 {
0187 }
0188 
0189 OpenWithPlugin::~OpenWithPlugin()
0190 {
0191 }
0192 
0193 KDevelop::ContextMenuExtension OpenWithPlugin::contextMenuExtension(KDevelop::Context* context, QWidget* parent)
0194 {
0195     // do not recurse
0196     if (context->hasType(KDevelop::Context::OpenWithContext)) {
0197         return ContextMenuExtension();
0198     }
0199 
0200     m_urls.clear();
0201 
0202     auto* filectx = dynamic_cast<FileContext*>( context );
0203     auto* projctx = dynamic_cast<ProjectItemContext*>( context );
0204     if ( filectx && filectx->urls().count() > 0 ) {
0205         m_urls = filectx->urls();
0206     } else if ( projctx && projctx->items().count() > 0 ) {
0207         // For now, let's handle *either* files only *or* directories only
0208         const auto items = projctx->items();
0209         const int wantedType = items.at(0)->type();
0210         for (ProjectBaseItem* item : items) {
0211             if (wantedType == ProjectBaseItem::File && item->file()) {
0212                 m_urls << item->file()->path().toUrl();
0213             } else if ((wantedType == ProjectBaseItem::Folder || wantedType == ProjectBaseItem::BuildFolder) && item->folder()) {
0214                 m_urls << item->folder()->path().toUrl();
0215             }
0216         }
0217     }
0218 
0219     if (m_urls.isEmpty()) {
0220         return KDevelop::ContextMenuExtension();
0221     }
0222 
0223     auto mimetype = updateMimeTypeForUrls();
0224 
0225     auto partActions = actionsForParts(parent);
0226     auto appActions = actionsForApplications(parent);
0227 
0228     OpenWithContext subContext(m_urls, mimetype);
0229     const QList<ContextMenuExtension> extensions = ICore::self()->pluginController()->queryPluginsForContextMenuExtensions( &subContext, parent);
0230     for (const ContextMenuExtension& ext : extensions) {
0231         appActions += ext.actions(ContextMenuExtension::OpenExternalGroup);
0232         partActions += ext.actions(ContextMenuExtension::OpenEmbeddedGroup);
0233     }
0234 
0235     {
0236         auto other = new QAction(i18nc("@item:menu", "Other..."), parent);
0237         connect(other, &QAction::triggered, this, [this] {
0238             auto dialog = new KOpenWithDialog(m_urls, ICore::self()->uiController()->activeMainWindow());
0239             if (dialog->exec() == QDialog::Accepted && dialog->service()) {
0240                 openApplication(dialog->service());
0241             }
0242         });
0243         appActions << other;
0244     }
0245 
0246     // Now setup a menu with actions for each part and app
0247     auto* menu = new QMenu(i18nc("@title:menu", "Open With"), parent);
0248     auto documentOpenIcon = QIcon::fromTheme( QStringLiteral("document-open") );
0249     menu->setIcon( documentOpenIcon );
0250 
0251     if (!partActions.isEmpty()) {
0252         menu->addSection(i18nc("@title:menu", "Embedded Editors"));
0253         menu->addActions( partActions );
0254     }
0255     if (!appActions.isEmpty()) {
0256         menu->addSection(i18nc("@title:menu", "External Applications"));
0257         menu->addActions( appActions );
0258     }
0259 
0260     KDevelop::ContextMenuExtension ext;
0261 
0262     if (canOpenDefault()) {
0263         auto* openAction = new QAction(i18nc("@action:inmenu", "Open"), parent);
0264         openAction->setIcon( documentOpenIcon );
0265         connect( openAction, &QAction::triggered, this, &OpenWithPlugin::openDefault );
0266         ext.addAction( KDevelop::ContextMenuExtension::FileGroup, openAction );
0267     }
0268 
0269     ext.addAction(KDevelop::ContextMenuExtension::FileGroup, menu->menuAction());
0270     return ext;
0271 }
0272 
0273 QList<QAction*> OpenWithPlugin::actionsForParts(QWidget* parent)
0274 {
0275     if (isDirectory(m_mimeType)) {
0276         // Don't return any parts for directories, KDevelop can only open files, not folders, thus
0277         // we wouldn't ever actually do anything with the parts shown to the user here.
0278         // Note that we can open a folder in an external application just fine though.
0279         return {};
0280     }
0281 
0282     const auto parts = KParts::PartLoader::partsForMimeType(m_mimeType);
0283     QList<QAction*> actions;
0284     actions.reserve(parts.size());
0285 
0286     int textEditorActionPos = -1;
0287     for (const auto& part : parts) {
0288         const auto pluginId = part.pluginId();
0289         const auto isTextEditor = isTextPart(pluginId);
0290 
0291         if (isTextEditor) {
0292             textEditorActionPos = actions.size();
0293         }
0294 
0295         const bool isDefault =
0296             m_defaultOpener.isValid() ? FileOpener::fromPartId(pluginId) == m_defaultOpener : isTextEditor;
0297         auto* action = createAction(isTextEditor ? i18nc("@item:inmenu", "Default Editor") : part.name(),
0298                                     part.iconName(), parent, isDefault);
0299         connect(action, &QAction::triggered, this, [this, action, pluginId]() {
0300             openPart(pluginId, action->text());
0301         });
0302         actions << action;
0303     }
0304 
0305     // partsForMimeType has the preferred part at the first position, let's keep it there
0306     int sortOffset = 1;
0307     if (textEditorActionPos > 0) {
0308         // move the text editor action up front
0309         actions.move(textEditorActionPos, 0);
0310         // keep the user-preferred mime at the 2nd pos
0311         sortOffset++;
0312     }
0313 
0314     return sortedActions(std::move(actions), sortOffset);
0315 }
0316 
0317 QList<QAction*> OpenWithPlugin::actionsForApplications(QWidget* parent)
0318 {
0319     const auto services = KApplicationTrader::queryByMimeType(m_mimeType);
0320     QList<QAction*> actions;
0321     actions.reserve(services.size());
0322 
0323     for (const auto& service : services) {
0324         const bool isDefault = m_defaultOpener.isValid() && service == m_defaultOpener;
0325         auto* action = createAction(service->name(), service->icon(), parent, isDefault);
0326         connect(action, &QAction::triggered, this, [this, service]() {
0327             openApplication(service);
0328         });
0329         actions << action;
0330     }
0331 
0332     // queryByMimeType returns the preferred service in the first position, let's keep it there
0333     const auto sortOffset = 1;
0334     return sortedActions(std::move(actions), sortOffset);
0335 }
0336 
0337 void OpenWithPlugin::openDefault() const
0338 {
0339     //  check preferred handler
0340     if (m_defaultOpener.isValid()) {
0341         if (m_defaultOpener.isPart()) {
0342             delegateToParts(m_defaultOpener.id());
0343         } else {
0344             delegateToExternalApplication(m_defaultOpener.service());
0345         }
0346         return;
0347     }
0348 
0349     // default handlers
0350     if (isDirectory(m_mimeType)) {
0351         KService::Ptr service = KApplicationTrader::preferredService(m_mimeType);
0352         delegateToExternalApplication(service);
0353     } else {
0354         for (const QUrl& u : qAsConst(m_urls)) {
0355             ICore::self()->documentController()->openDocument( u );
0356         }
0357     }
0358 }
0359 
0360 void OpenWithPlugin::delegateToParts(const QString& pluginId) const
0361 {
0362     auto prefName = pluginId;
0363     if (isTextPart(pluginId)) {
0364         // If the user chose a KTE part, lets make sure we're creating a TextDocument instead of
0365         // a PartDocument by passing no preferredpart to the documentcontroller
0366         // TODO: Solve this rather inside DocumentController
0367         prefName.clear();
0368     }
0369     for (const QUrl& u : qAsConst(m_urls)) {
0370         ICore::self()->documentController()->openDocument(u, prefName);
0371     }
0372 }
0373 
0374 void OpenWithPlugin::openPart(const QString& pluginId, const QString& name)
0375 {
0376     delegateToParts(pluginId);
0377     rememberDefaultChoice(FileOpener::fromPartId(pluginId), name);
0378 }
0379 
0380 void OpenWithPlugin::delegateToExternalApplication(const KService::Ptr& service) const
0381 {
0382     Q_ASSERT(service->isApplication());
0383 
0384     auto* job = new KIO::ApplicationLauncherJob(service);
0385     job->setUrls(m_urls);
0386 #if KIO_VERSION < QT_VERSION_CHECK(5, 98, 0)
0387     job->setUiDelegate(new KIO::JobUiDelegate(KJobUiDelegate::AutoHandlingEnabled,
0388 #else
0389     job->setUiDelegate(KIO::createDefaultJobUiDelegate(KJobUiDelegate::AutoHandlingEnabled,
0390 #endif
0391                                               ICore::self()->uiController()->activeMainWindow()));
0392     job->start();
0393 }
0394 
0395 void OpenWithPlugin::openApplication(const KService::Ptr& service)
0396 {
0397     delegateToExternalApplication(service);
0398     rememberDefaultChoice(service, service->name());
0399 }
0400 
0401 void OpenWithPlugin::openFilesInternal( const QList<QUrl>& files )
0402 {
0403     if (files.isEmpty()) {
0404         return;
0405     }
0406 
0407     m_urls = files;
0408     updateMimeTypeForUrls();
0409     openDefault();
0410 }
0411 
0412 void OpenWithPlugin::rememberDefaultChoice(const FileOpener& opener, const QString& name)
0413 {
0414     if (m_defaultOpener.isValid() && opener == m_defaultOpener) {
0415         return;
0416     }
0417 
0418     const auto setDefault = KMessageBox::questionTwoActions(
0419         qApp->activeWindow(),
0420         i18nc("%1: mime type name, %2: app/part name", "Do you want to open all '%1' files by default with %2?",
0421               m_mimeType, name),
0422         i18nc("@title:window", "Set as Default?"),
0423         KGuiItem(i18nc("@action:button", "Set as Default"), QStringLiteral("dialog-ok")),
0424         KGuiItem(i18nc("@action:button", "Do Not Set"), QStringLiteral("dialog-cancel")),
0425         QStringLiteral("OpenWith-%1").arg(m_mimeType));
0426 
0427     if (setDefault == KMessageBox::PrimaryAction) {
0428         m_defaultOpener = opener;
0429         defaultsConfig().writeEntry(m_mimeType, m_defaultOpener.toConfigEntryValue());
0430     }
0431 }
0432 
0433 QMimeType OpenWithPlugin::updateMimeTypeForUrls()
0434 {
0435     // Fetch the MIME type of the !!first!! URL.
0436     // TODO: think about possible alternatives to using the MIME type of the first URL.
0437     auto mimeType = QMimeDatabase().mimeTypeForUrl(m_urls.first());
0438     m_mimeType = mimeType.name();
0439 
0440     const auto config = defaultsConfig();
0441     m_defaultOpener = FileOpener::fromConfigEntryValue(config.readEntry(m_mimeType, QString()));
0442 
0443     return mimeType;
0444 }
0445 
0446 #include "openwithplugin.moc"
0447 #include "moc_openwithplugin.cpp"