File indexing completed on 2024-05-05 16:46:03
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"