1/*
2 This file is part of the KDE libraries
3 SPDX-FileCopyrightText: 1999 Reginald Stadlbauer <reggie@kde.org>
4 SPDX-FileCopyrightText: 1999 Simon Hausmann <hausmann@kde.org>
5 SPDX-FileCopyrightText: 2000 Nicolas Hadacek <haadcek@kde.org>
6 SPDX-FileCopyrightText: 2000 Kurt Granroth <granroth@kde.org>
7 SPDX-FileCopyrightText: 2000 Michael Koch <koch@kde.org>
8 SPDX-FileCopyrightText: 2001 Holger Freyther <freyther@kde.org>
9 SPDX-FileCopyrightText: 2002 Ellis Whitehead <ellis@kde.org>
10 SPDX-FileCopyrightText: 2002 Joseph Wenninger <jowenn@kde.org>
11 SPDX-FileCopyrightText: 2003 Andras Mantia <amantia@kde.org>
12 SPDX-FileCopyrightText: 2005-2006 Hamish Rodda <rodda@kde.org>
13
14 SPDX-License-Identifier: LGPL-2.0-only
15*/
16
17#include "krecentfilesaction.h"
18#include "krecentfilesaction_p.h"
19
20#include <QActionGroup>
21#include <QDir>
22#include <QGuiApplication>
23#include <QMenu>
24#include <QMimeDatabase>
25#include <QMimeType>
26#include <QScreen>
27
28#if HAVE_QTDBUS
29#include <QDBusConnectionInterface>
30#include <QDBusInterface>
31#include <QDBusMessage>
32#endif
33
34#include <KConfig>
35#include <KConfigGroup>
36#include <KLocalizedString>
37#include <KShell>
38
39#include <set>
40
41KRecentFilesAction::KRecentFilesAction(QObject *parent)
42 : KSelectAction(parent)
43 , d_ptr(new KRecentFilesActionPrivate(this))
44{
45 Q_D(KRecentFilesAction);
46 d->init();
47}
48
49KRecentFilesAction::KRecentFilesAction(const QString &text, QObject *parent)
50 : KSelectAction(parent)
51 , d_ptr(new KRecentFilesActionPrivate(this))
52{
53 Q_D(KRecentFilesAction);
54 d->init();
55
56 // Want to keep the ampersands
57 setText(text);
58}
59
60KRecentFilesAction::KRecentFilesAction(const QIcon &icon, const QString &text, QObject *parent)
61 : KSelectAction(parent)
62 , d_ptr(new KRecentFilesActionPrivate(this))
63{
64 Q_D(KRecentFilesAction);
65 d->init();
66
67 setIcon(icon);
68 // Want to keep the ampersands
69 setText(text);
70}
71
72void KRecentFilesActionPrivate::init()
73{
74 Q_Q(KRecentFilesAction);
75 delete q->menu();
76 q->setMenu(new QMenu());
77 q->setToolBarMode(KSelectAction::MenuMode);
78 m_noEntriesAction = q->menu()->addAction(i18n("No Entries"));
79 m_noEntriesAction->setObjectName(QStringLiteral("no_entries"));
80 m_noEntriesAction->setEnabled(false);
81 clearSeparator = q->menu()->addSeparator();
82 clearSeparator->setVisible(false);
83 clearSeparator->setObjectName(QStringLiteral("separator"));
84 clearAction = q->menu()->addAction(icon: QIcon::fromTheme(QStringLiteral("edit-clear-history")), i18n("Clear List"), args: q, args: &KRecentFilesAction::clear);
85 clearAction->setObjectName(QStringLiteral("clear_action"));
86 clearAction->setVisible(false);
87 q->setEnabled(false);
88 q->connect(sender: q, signal: &KSelectAction::actionTriggered, context: q, slot: [this](QAction *action) {
89 urlSelected(action);
90 });
91
92 q->connect(sender: q->menu(), signal: &QMenu::aboutToShow, context: q, slot: [q] {
93 std::vector<RecentActionInfo> &recentActions = q->d_ptr->m_recentActions;
94 // Set icons lazily based on the mimetype
95 for (auto action : recentActions) {
96 if (action.action->icon().isNull()) {
97 if (!action.mimeType.isValid()) {
98 action.mimeType = QMimeDatabase().mimeTypeForFile(fileName: action.url.path(), mode: QMimeDatabase::MatchExtension);
99 }
100
101 if (!action.mimeType.isDefault()) {
102 action.action->setIcon(QIcon::fromTheme(name: action.mimeType.iconName()));
103 }
104 }
105 }
106 });
107}
108
109KRecentFilesAction::~KRecentFilesAction() = default;
110
111void KRecentFilesActionPrivate::urlSelected(QAction *action)
112{
113 Q_Q(KRecentFilesAction);
114
115 auto it = findByAction(action);
116
117 Q_ASSERT(it != m_recentActions.cend()); // Should never happen
118
119 const QUrl url = it->url; // BUG: 461448; see iterator invalidation rules
120 Q_EMIT q->urlSelected(url);
121}
122
123// TODO: remove this helper function, it will crash if you use it in a loop
124void KRecentFilesActionPrivate::removeAction(std::vector<RecentActionInfo>::iterator it)
125{
126 Q_Q(KRecentFilesAction);
127 delete q->KSelectAction::removeAction(action: it->action);
128 m_recentActions.erase(position: it);
129}
130
131int KRecentFilesAction::maxItems() const
132{
133 Q_D(const KRecentFilesAction);
134 return d->m_maxItems;
135}
136
137void KRecentFilesAction::setMaxItems(int maxItems)
138{
139 Q_D(KRecentFilesAction);
140 // set new maxItems
141 d->m_maxItems = std::max(a: maxItems, b: 0);
142
143 // Remove all excess items, oldest (i.e. first added) first
144 const int difference = static_cast<int>(d->m_recentActions.size()) - d->m_maxItems;
145 if (difference > 0) {
146 auto beginIt = d->m_recentActions.begin();
147 auto endIt = d->m_recentActions.begin() + difference;
148 for (auto it = beginIt; it < endIt; ++it) {
149 // Remove the action from the menus, action groups ...etc
150 delete KSelectAction::removeAction(action: it->action);
151 }
152 d->m_recentActions.erase(first: beginIt, last: endIt);
153 }
154}
155
156static QString titleWithSensibleWidth(const QString &nameValue, const QString &value)
157{
158 // Calculate 3/4 of screen geometry, we do not want
159 // action titles to be bigger than that
160 // Since we do not know in which screen we are going to show
161 // we choose the min of all the screens
162 int maxWidthForTitles = INT_MAX;
163 const auto screens = QGuiApplication::screens();
164 for (QScreen *screen : screens) {
165 maxWidthForTitles = qMin(a: maxWidthForTitles, b: screen->availableGeometry().width() * 3 / 4);
166 }
167 const QFontMetrics fontMetrics = QFontMetrics(QFont());
168
169 QString title = nameValue + QLatin1String(" [") + value + QLatin1Char(']');
170 const int nameWidth = fontMetrics.boundingRect(text: title).width();
171 if (nameWidth > maxWidthForTitles) {
172 // If it does not fit, try to cut only the whole path, though if the
173 // name is too long (more than 3/4 of the whole text) we cut it a bit too
174 const int nameValueMaxWidth = maxWidthForTitles * 3 / 4;
175 QString cutNameValue;
176 QString cutValue;
177 if (nameWidth > nameValueMaxWidth) {
178 cutNameValue = fontMetrics.elidedText(text: nameValue, mode: Qt::ElideMiddle, width: nameValueMaxWidth);
179 cutValue = fontMetrics.elidedText(text: value, mode: Qt::ElideMiddle, width: maxWidthForTitles - nameValueMaxWidth);
180 } else {
181 cutNameValue = nameValue;
182 cutValue = fontMetrics.elidedText(text: value, mode: Qt::ElideMiddle, width: maxWidthForTitles - nameWidth);
183 }
184 title = cutNameValue + QLatin1String(" [") + cutValue + QLatin1Char(']');
185 }
186 return title;
187}
188
189void KRecentFilesAction::addUrl(const QUrl &url, const QString &name)
190{
191 addUrl(url, name, mimeType: QString());
192}
193
194void KRecentFilesAction::addUrl(const QUrl &url, const QString &name, const QString &mimeTypeStr)
195{
196 Q_D(KRecentFilesAction);
197
198 // ensure we never add items if we want none
199 if (d->m_maxItems == 0) {
200 return;
201 }
202
203 if (url.isLocalFile() && url.toLocalFile().startsWith(s: QDir::tempPath())) {
204 return;
205 }
206
207 // Remove url if it already exists in the list
208 removeUrl(url);
209
210 // Remove oldest item if already maxItems in list
211 Q_ASSERT(d->m_maxItems > 0);
212 if (static_cast<int>(d->m_recentActions.size()) == d->m_maxItems) {
213 d->removeAction(it: d->m_recentActions.begin());
214 }
215
216 const QString pathOrUrl(url.toDisplayString(options: QUrl::PreferLocalFile));
217 const QString tmpName = !name.isEmpty() ? name : url.fileName();
218#ifdef Q_OS_WIN
219 const QString file = url.isLocalFile() ? QDir::toNativeSeparators(pathOrUrl) : pathOrUrl;
220#else
221 const QString file = pathOrUrl;
222#endif
223
224 d->m_noEntriesAction->setVisible(false);
225 d->clearSeparator->setVisible(true);
226 d->clearAction->setVisible(true);
227 setEnabled(true);
228 // add file to list
229 const QString title = titleWithSensibleWidth(nameValue: tmpName, value: KShell::tildeCollapse(path: file));
230
231#if HAVE_QTDBUS
232 static bool isKdeSession = qgetenv(varName: "XDG_CURRENT_DESKTOP") == "KDE";
233 if (isKdeSession) {
234 const QDBusConnection bus = QDBusConnection::sessionBus();
235 if (bus.isConnected()
236 && bus.interface()->isServiceRegistered(QStringLiteral("org.kde.ActivityManager"))
237 // skip files in hidden directories
238 && !url.path().contains(QStringLiteral("/."))) {
239 const static QString activityService = QStringLiteral("org.kde.ActivityManager");
240 const static QString activityResources = QStringLiteral("/ActivityManager/Resources");
241 const static QString activityResouceInferface = QStringLiteral("org.kde.ActivityManager.Resources");
242
243 QString mimeTypeName = mimeTypeStr;
244 if (mimeTypeName.isEmpty()) {
245 mimeTypeName = QMimeDatabase().mimeTypeForFile(fileName: url.path(), mode: QMimeDatabase::MatchExtension).name();
246 }
247
248 const auto urlString = url.toString(options: QUrl::PreferLocalFile);
249 QDBusMessage message =
250 QDBusMessage::createMethodCall(destination: activityService, path: activityResources, interface: activityResouceInferface, QStringLiteral("RegisterResourceEvent"));
251 message.setArguments({qApp->desktopFileName(), uint(0) /* WinId */, urlString, uint(0) /* eventType Accessed */});
252 bus.asyncCall(message);
253
254 message = QDBusMessage::createMethodCall(destination: activityService, path: activityResources, interface: activityResouceInferface, QStringLiteral("RegisterResourceMimetype"));
255 message.setArguments({urlString, mimeTypeName});
256 bus.asyncCall(message);
257
258 message = QDBusMessage::createMethodCall(destination: activityService, path: activityResources, interface: activityResouceInferface, QStringLiteral("RegisterResourceTitle"));
259 message.setArguments({urlString, url.fileName()});
260 bus.asyncCall(message);
261 }
262 }
263#endif
264
265 QAction *action = new QAction(title, selectableActionGroup());
266 addAction(action, url, name: tmpName, mimeType: QMimeType());
267}
268
269void KRecentFilesAction::addAction(QAction *action, const QUrl &url, const QString &name, const QMimeType &mimeType)
270{
271 Q_D(KRecentFilesAction);
272 menu()->insertAction(before: menu()->actions().value(i: 0), action);
273 d->m_recentActions.push_back(x: {.action: action, .url: url, .shortName: name, .mimeType: mimeType});
274}
275
276QAction *KRecentFilesAction::removeAction(QAction *action)
277{
278 Q_D(KRecentFilesAction);
279 auto it = d->findByAction(action);
280 Q_ASSERT(it != d->m_recentActions.cend());
281 d->m_recentActions.erase(position: it);
282 return KSelectAction::removeAction(action);
283}
284
285void KRecentFilesAction::removeUrl(const QUrl &url)
286{
287 Q_D(KRecentFilesAction);
288
289 auto it = d->findByUrl(url);
290
291 if (it != d->m_recentActions.cend()) {
292 d->removeAction(it);
293 };
294}
295
296QList<QUrl> KRecentFilesAction::urls() const
297{
298 Q_D(const KRecentFilesAction);
299
300 QList<QUrl> list;
301 list.reserve(asize: d->m_recentActions.size());
302
303 using Info = KRecentFilesActionPrivate::RecentActionInfo;
304 // Reverse order to match how the actions appear in the menu
305 std::transform(first: d->m_recentActions.crbegin(), last: d->m_recentActions.crend(), result: std::back_inserter(x&: list), unary_op: [](const Info &info) {
306 return info.url;
307 });
308
309 return list;
310}
311
312void KRecentFilesAction::clear()
313{
314 clearEntries();
315 Q_EMIT recentListCleared();
316}
317
318void KRecentFilesAction::clearEntries()
319{
320 Q_D(KRecentFilesAction);
321 KSelectAction::clear();
322 d->m_recentActions.clear();
323 d->m_noEntriesAction->setVisible(true);
324 d->clearSeparator->setVisible(false);
325 d->clearAction->setVisible(false);
326 setEnabled(false);
327}
328
329void KRecentFilesAction::loadEntries(const KConfigGroup &_config)
330{
331 Q_D(KRecentFilesAction);
332 clearEntries();
333
334 QString key;
335 QString value;
336 QString nameKey;
337 QString nameValue;
338 QString title;
339 QUrl url;
340
341 KConfigGroup cg = _config;
342 // "<default>" means the group was constructed with an empty name
343 if (cg.name() == QLatin1String("<default>")) {
344 cg = KConfigGroup(cg.config(), QStringLiteral("RecentFiles"));
345 }
346
347 std::set<QUrl> seenUrls;
348
349 bool thereAreEntries = false;
350 // read file list
351 for (int i = 1; i <= d->m_maxItems; i++) {
352 key = QStringLiteral("File%1").arg(a: i);
353 value = cg.readPathEntry(pKey: key, aDefault: QString());
354 if (value.isEmpty()) {
355 continue;
356 }
357 url = QUrl::fromUserInput(userInput: value);
358
359 auto [it, isNewUrl] = seenUrls.insert(x: url);
360 // Don't restore if this url has already been restored (e.g. broken config)
361 if (!isNewUrl) {
362 continue;
363 }
364
365#ifdef Q_OS_WIN
366 // convert to backslashes
367 if (url.isLocalFile()) {
368 value = QDir::toNativeSeparators(value);
369 }
370#endif
371
372 nameKey = QStringLiteral("Name%1").arg(a: i);
373 nameValue = cg.readPathEntry(pKey: nameKey, aDefault: url.fileName());
374 title = titleWithSensibleWidth(nameValue, value: KShell::tildeCollapse(path: value));
375 if (!value.isNull()) {
376 thereAreEntries = true;
377 addAction(action: new QAction(title, selectableActionGroup()), url, name: nameValue);
378 }
379 }
380 if (thereAreEntries) {
381 d->m_noEntriesAction->setVisible(false);
382 d->clearSeparator->setVisible(true);
383 d->clearAction->setVisible(true);
384 setEnabled(true);
385 }
386}
387
388void KRecentFilesAction::saveEntries(const KConfigGroup &_cg)
389{
390 Q_D(KRecentFilesAction);
391
392 KConfigGroup cg = _cg;
393 // "<default>" means the group was constructed with an empty name
394 if (cg.name() == QLatin1String("<default>")) {
395 cg = KConfigGroup(cg.config(), QStringLiteral("RecentFiles"));
396 }
397
398 cg.deleteGroup();
399
400 // write file list
401 int i = 1;
402 for (const auto &[action, url, shortName, _] : d->m_recentActions) {
403 cg.writePathEntry(QStringLiteral("File%1").arg(a: i), path: url.toDisplayString(options: QUrl::PreferLocalFile));
404 cg.writePathEntry(QStringLiteral("Name%1").arg(a: i), path: shortName);
405
406 ++i;
407 }
408}
409
410#include "moc_krecentfilesaction.cpp"
411

source code of kconfigwidgets/src/krecentfilesaction.cpp