]> cloud.milkyroute.net Git - dolphin.git/commitdiff
Merge remote-tracking branch 'upstream/master' into work/zakharafoniam/useful-groups
authorZakhar Afonin <zakharafoniam@gmail.com>
Sat, 28 Sep 2024 08:17:27 +0000 (11:17 +0300)
committerZakhar Afonin <zakharafoniam@gmail.com>
Sat, 28 Sep 2024 08:17:27 +0000 (11:17 +0300)
1  2 
src/dolphincontextmenu.cpp
src/dolphinmainwindow.cpp
src/dolphinui.rc
src/kitemviews/kfileitemmodel.cpp
src/kitemviews/kfileitemmodel.h
src/kitemviews/kfileitemmodelrolesupdater.cpp
src/settings/contextmenu/contextmenusettingspage.cpp
src/settings/dolphinsettingsdialog.cpp
src/views/dolphinview.cpp
src/views/dolphinview.h
src/views/viewproperties.cpp

index bc00af7cc5009d7022c836e93e722d256c2d5ec3,3ce1d1d51c82fb178d202f458d00c5ba82ee9f83..15c65ee565946308a6b4eb6453614d623a004cbc
@@@ -7,6 -7,7 +7,7 @@@
  #include "dolphincontextmenu.h"
  
  #include "dolphin_contextmenusettings.h"
+ #include "dolphin_generalsettings.h"
  #include "dolphinmainwindow.h"
  #include "dolphinnewfilemenu.h"
  #include "dolphinplacesmodelsingleton.h"
@@@ -305,13 -306,10 +306,13 @@@ void DolphinContextMenu::addViewportCon
      }
      addSeparator();
  
 -    // Insert 'Sort By' and 'View Mode'
 +    // Insert 'Sort By', 'Group By' and 'View Mode'
      if (ContextMenuSettings::showSortBy()) {
          addAction(m_mainWindow->actionCollection()->action(QStringLiteral("sort")));
      }
 +    if (ContextMenuSettings::showGroupBy()) {
 +        addAction(m_mainWindow->actionCollection()->action(QStringLiteral("group")));
 +    }
      if (ContextMenuSettings::showViewMode()) {
          addAction(m_mainWindow->actionCollection()->action(QStringLiteral("view_mode")));
      }
@@@ -383,9 -381,8 +384,8 @@@ bool DolphinContextMenu::placeExists(co
  {
      const KFilePlacesModel *placesModel = DolphinPlacesModelSingleton::instance().placesModel();
  
-     const auto &matchedPlaces = placesModel->match(placesModel->index(0, 0), KFilePlacesModel::UrlRole, url, 1, Qt::MatchExactly);
-     return !matchedPlaces.isEmpty();
+     QModelIndex url_index = placesModel->closestItem(url);
+     return url_index.isValid() && placesModel->url(url_index).matches(url, QUrl::StripTrailingSlash);
  }
  
  QAction *DolphinContextMenu::createPasteAction()
@@@ -444,7 -441,9 +444,9 @@@ void DolphinContextMenu::addOpenWithAct
      m_fileItemActions->insertOpenWithActionsTo(nullptr, this, QStringList{qApp->desktopFileName()});
  
      // For a single file, hint in "Open with" menu that middle-clicking would open it in the secondary app.
-     if (m_selectedItems.count() == 1 && !m_fileInfo.isDir()) {
+     // (Unless middle-clicking would open it as a folder in a new tab (e.g. archives).)
+     const QUrl &url = DolphinView::openItemAsFolderUrl(m_fileInfo, GeneralSettings::browseThroughArchives());
+     if (m_selectedItems.count() == 1 && url.isEmpty()) {
          if (QAction *openWithSubMenu = findChild<QAction *>(QStringLiteral("openWith_submenu"))) {
              Q_ASSERT(openWithSubMenu->menu());
              Q_ASSERT(!openWithSubMenu->menu()->isEmpty());
index cb94e86572962edc05b8df2720444d3f3ef58b04,54cd3bf71b974d6fa66ad340302d12f31321d8a6..17396dabd90d966b701a7bb581c916360ec22b5c
@@@ -53,6 -53,7 +53,7 @@@
  #include <KMessageBox>
  #include <KProtocolInfo>
  #include <KProtocolManager>
+ #include <KRecentFilesAction>
  #include <KShell>
  #include <KShortcutsDialog>
  #include <KStandardAction>
@@@ -184,7 -185,7 +185,7 @@@ DolphinMainWindow::DolphinMainWindow(
  
      m_disabledActionNotifier = new DisabledActionNotifier(this);
      connect(m_disabledActionNotifier, &DisabledActionNotifier::disabledActionTriggered, this, [this](const QAction *, QString reason) {
-         m_activeViewContainer->showMessage(reason, DolphinViewContainer::Warning);
+         m_activeViewContainer->showMessage(reason, KMessageWidget::Warning);
      });
  
      setupDockWidgets();
@@@ -240,6 -241,13 +241,13 @@@ DolphinMainWindow::~DolphinMainWindow(
  {
      // This fixes a crash on Wayland when closing the mainwindow while another dialog is open.
      disconnect(QGuiApplication::clipboard(), &QClipboard::dataChanged, this, &DolphinMainWindow::updatePasteAction);
+     // This fixes a crash in dolphinmainwindowtest where the connection below fires even though the KMainWindow destructor of this object is already running.
+     Q_CHECK_PTR(qobject_cast<DolphinDockWidget *>(m_placesPanel->parent()));
+     disconnect(static_cast<DolphinDockWidget *>(m_placesPanel->parent()),
+                &DolphinDockWidget::visibilityChanged,
+                this,
+                &DolphinMainWindow::slotPlacesPanelVisibilityChanged);
  }
  
  QVector<DolphinViewContainer *> DolphinMainWindow::viewContainers() const
@@@ -365,6 -373,9 +373,9 @@@ void DolphinMainWindow::changeUrl(cons
      updateViewActions();
      updateGoActions();
  
+     // will signal used urls to activities manager, too
+     m_recentFiles->addUrl(url);
      Q_EMIT urlChanged(url);
  }
  
@@@ -562,7 -573,7 +573,7 @@@ void DolphinMainWindow::showTarget(
          KIO::StatJob *statJob = static_cast<KIO::StatJob *>(job);
  
          if (statJob->error()) {
-             m_activeViewContainer->showMessage(job->errorString(), DolphinViewContainer::Error);
+             m_activeViewContainer->showMessage(job->errorString(), KMessageWidget::Error);
          } else {
              KIO::highlightInFileManager({destinationUrl});
          }
@@@ -586,7 -597,7 +597,7 @@@ void DolphinMainWindow::showEvent(QShow
  {
      KXmlGuiWindow::showEvent(event);
  
-     if (!event->spontaneous()) {
+     if (!event->spontaneous() && m_activeViewContainer) {
          m_activeViewContainer->view()->setFocus();
      }
  }
@@@ -801,7 -812,7 +812,7 @@@ void DolphinMainWindow::quit(
  
  void DolphinMainWindow::showErrorMessage(const QString &message)
  {
-     m_activeViewContainer->showMessage(message, DolphinViewContainer::Error);
+     m_activeViewContainer->showMessage(message, KMessageWidget::Error);
  }
  
  void DolphinMainWindow::slotUndoAvailable(bool available)
@@@ -1132,11 -1143,21 +1143,21 @@@ void DolphinMainWindow::togglePanelLock
      GeneralSettings::setLockPanels(newLockState);
  }
  
- void DolphinMainWindow::slotTerminalPanelVisibilityChanged()
+ void DolphinMainWindow::slotTerminalPanelVisibilityChanged(bool visible)
+ {
+     if (!visible && m_activeViewContainer) {
+         m_activeViewContainer->view()->setFocus();
+     }
+     // Putting focus to the Terminal is not handled here but in TerminalPanel::showEvent().
+ }
+ void DolphinMainWindow::slotPlacesPanelVisibilityChanged(bool visible)
  {
-     if (m_terminalPanel->isHiddenInVisibleWindow() && m_activeViewContainer) {
+     if (!visible && m_activeViewContainer) {
          m_activeViewContainer->view()->setFocus();
+         return;
      }
+     m_placesPanel->setFocus();
  }
  
  void DolphinMainWindow::goBack()
@@@ -1496,7 -1517,6 +1517,7 @@@ void DolphinMainWindow::updateHamburger
      }
      menu->addAction(ac->action(QStringLiteral("show_hidden_files")));
      menu->addAction(ac->action(QStringLiteral("sort")));
 +    menu->addAction(ac->action(QStringLiteral("group")));
      menu->addAction(ac->action(QStringLiteral("additional_info")));
      if (!GeneralSettings::showStatusBar() || !GeneralSettings::showZoomSlider()) {
          menu->addAction(ac->action(QStringLiteral("zoom")));
@@@ -2043,14 -2063,6 +2064,6 @@@ void DolphinMainWindow::setupActions(
          openTerminalHere->setIcon(QIcon::fromTheme(QStringLiteral("utilities-terminal")));
          actionCollection()->setDefaultShortcut(openTerminalHere, Qt::SHIFT | Qt::ALT | Qt::Key_F4);
          connect(openTerminalHere, &QAction::triggered, this, &DolphinMainWindow::openTerminalHere);
- #if HAVE_TERMINAL
-         QAction *focusTerminalPanel = actionCollection()->addAction(QStringLiteral("focus_terminal_panel"));
-         focusTerminalPanel->setText(i18nc("@action:inmenu Tools", "Focus Terminal Panel"));
-         focusTerminalPanel->setIcon(QIcon::fromTheme(QStringLiteral("swap-panels")));
-         actionCollection()->setDefaultShortcut(focusTerminalPanel, Qt::CTRL | Qt::SHIFT | Qt::Key_F4);
-         connect(focusTerminalPanel, &QAction::triggered, this, &DolphinMainWindow::focusTerminalPanel);
- #endif
      }
  
      // setup 'Bookmarks' menu
      connect(openInSplitViewAction, &QAction::triggered, this, [this]() {
          openInSplitView(QUrl());
      });
+     m_recentFiles = new KRecentFilesAction(this);
  }
  
  void DolphinMainWindow::setupDockWidgets()
      connect(infoPanel, &InformationPanel::urlActivated, this, &DolphinMainWindow::handleUrl);
      infoDock->setWidget(infoPanel);
  
-     createPanelAction(QIcon::fromTheme(QStringLiteral("dialog-information")), Qt::Key_F11, infoDock, QStringLiteral("show_information_panel"));
+     createPanelAction(QIcon::fromTheme(QStringLiteral("documentinfo")), Qt::Key_F11, infoDock, QStringLiteral("show_information_panel"));
  
      addDockWidget(Qt::RightDockWidgetArea, infoDock);
      connect(this, &DolphinMainWindow::urlChanged, infoPanel, &InformationPanel::setUrl);
                                            "advanced tasks. To learn more about terminals use the help features in a "
                                            "standalone terminal application like Konsole.</para>")
                                     + panelWhatsThis);
-     }
- #endif
+         QAction *focusTerminalPanel = actionCollection()->addAction(QStringLiteral("focus_terminal_panel"));
+         focusTerminalPanel->setText(i18nc("@action:inmenu Tools", "Focus Terminal Panel"));
+         focusTerminalPanel->setToolTip(i18nc("@info:tooltip", "Move keyboard focus to and from the Terminal panel."));
+         focusTerminalPanel->setIcon(QIcon::fromTheme(QStringLiteral("swap-panels")));
+         actionCollection()->setDefaultShortcut(focusTerminalPanel, Qt::CTRL | Qt::SHIFT | Qt::Key_F4);
+         connect(focusTerminalPanel, &QAction::triggered, this, &DolphinMainWindow::toggleTerminalPanelFocus);
+     } // endif "shell_access" allowed
+ #endif // HAVE_TERMINAL
  
      if (GeneralSettings::version() < 200) {
          infoDock->hide();
      placesDock->setLocked(lock);
      placesDock->setObjectName(QStringLiteral("placesDock"));
      placesDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
 -
 +    
      m_placesPanel = new PlacesPanel(placesDock);
      m_placesPanel->setCustomContextMenuActions({lockLayoutAction});
      placesDock->setWidget(m_placesPanel);
      connect(m_placesPanel, &PlacesPanel::errorMessage, this, &DolphinMainWindow::showErrorMessage);
      connect(this, &DolphinMainWindow::urlChanged, m_placesPanel, &PlacesPanel::setUrl);
      connect(placesDock, &DolphinDockWidget::visibilityChanged, &DolphinUrlNavigatorsController::slotPlacesPanelVisibilityChanged);
+     connect(placesDock, &DolphinDockWidget::visibilityChanged, this, &DolphinMainWindow::slotPlacesPanelVisibilityChanged);
      connect(this, &DolphinMainWindow::settingsChanged, m_placesPanel, &PlacesPanel::readSettings);
      connect(m_placesPanel, &PlacesPanel::storageTearDownRequested, this, &DolphinMainWindow::slotStorageTearDownFromPlacesRequested);
      connect(m_placesPanel, &PlacesPanel::storageTearDownExternallyRequested, this, &DolphinMainWindow::slotStorageTearDownExternallyRequested);
                                      "</interface> to display it again.</para>")
                               + panelWhatsThis);
  
+     QAction *focusPlacesPanel = actionCollection()->addAction(QStringLiteral("focus_places_panel"));
+     focusPlacesPanel->setText(i18nc("@action:inmenu View", "Focus Places Panel"));
+     focusPlacesPanel->setToolTip(i18nc("@info:tooltip", "Move keyboard focus to and from the Places panel."));
+     focusPlacesPanel->setIcon(QIcon::fromTheme(QStringLiteral("swap-panels")));
+     actionCollection()->setDefaultShortcut(focusPlacesPanel, Qt::CTRL | Qt::Key_P);
+     connect(focusPlacesPanel, &QAction::triggered, this, &DolphinMainWindow::togglePlacesPanelFocus);
      // Add actions into the "Panels" menu
      KActionMenu *panelsMenu = new KActionMenu(i18nc("@action:inmenu View", "Show Panels"), this);
      actionCollection()->addAction(QStringLiteral("panels"), panelsMenu);
      panelsMenu->addAction(ac->action(QStringLiteral("show_folders_panel")));
      panelsMenu->addAction(ac->action(QStringLiteral("show_terminal_panel")));
      panelsMenu->addSeparator();
-     panelsMenu->addAction(actionShowAllPlaces);
      panelsMenu->addAction(lockLayoutAction);
+     panelsMenu->addSeparator();
+     panelsMenu->addAction(actionShowAllPlaces);
+     panelsMenu->addAction(focusPlacesPanel);
+     panelsMenu->addAction(ac->action(QStringLiteral("focus_terminal_panel")));
  
      connect(panelsMenu->menu(), &QMenu::aboutToShow, this, [actionShowAllPlaces] {
          actionShowAllPlaces->setEnabled(DolphinPlacesModelSingleton::instance().placesModel()->hiddenCount());
@@@ -2869,20 -2901,40 +2902,40 @@@ void DolphinMainWindow::saveNewToolbarC
      (static_cast<KHamburgerMenu *>(actionCollection()->action(KStandardAction::name(KStandardAction::HamburgerMenu))))->hideActionsOf(toolBar());
  }
  
- void DolphinMainWindow::focusTerminalPanel()
+ void DolphinMainWindow::toggleTerminalPanelFocus()
  {
-     if (m_terminalPanel->isVisible()) {
-         if (m_terminalPanel->terminalHasFocus()) {
-             m_activeViewContainer->view()->setFocus(Qt::FocusReason::ShortcutFocusReason);
-             actionCollection()->action(QStringLiteral("focus_terminal_panel"))->setText(i18nc("@action:inmenu Tools", "Focus Terminal Panel"));
-         } else {
-             m_terminalPanel->setFocus(Qt::FocusReason::ShortcutFocusReason);
-             actionCollection()->action(QStringLiteral("focus_terminal_panel"))->setText(i18nc("@action:inmenu Tools", "Defocus Terminal Panel"));
-         }
-     } else {
-         actionCollection()->action(QStringLiteral("show_terminal_panel"))->trigger();
+     if (!m_terminalPanel->isVisible()) {
+         actionCollection()->action(QStringLiteral("show_terminal_panel"))->trigger(); // Also moves focus to the panel.
          actionCollection()->action(QStringLiteral("focus_terminal_panel"))->setText(i18nc("@action:inmenu Tools", "Defocus Terminal Panel"));
+         return;
+     }
+     if (m_terminalPanel->terminalHasFocus()) {
+         m_activeViewContainer->view()->setFocus(Qt::FocusReason::ShortcutFocusReason);
+         actionCollection()->action(QStringLiteral("focus_terminal_panel"))->setText(i18nc("@action:inmenu Tools", "Focus Terminal Panel"));
+         return;
      }
+     m_terminalPanel->setFocus(Qt::FocusReason::ShortcutFocusReason);
+     actionCollection()->action(QStringLiteral("focus_terminal_panel"))->setText(i18nc("@action:inmenu Tools", "Defocus Terminal Panel"));
+ }
+ void DolphinMainWindow::togglePlacesPanelFocus()
+ {
+     if (!m_placesPanel->isVisible()) {
+         actionCollection()->action(QStringLiteral("show_places_panel"))->trigger(); // Also moves focus to the panel.
+         actionCollection()->action(QStringLiteral("focus_places_panel"))->setText(i18nc("@action:inmenu View", "Defocus Terminal Panel"));
+         return;
+     }
+     if (m_placesPanel->hasFocus()) {
+         m_activeViewContainer->view()->setFocus(Qt::FocusReason::ShortcutFocusReason);
+         actionCollection()->action(QStringLiteral("focus_places_panel"))->setText(i18nc("@action:inmenu View", "Focus Places Panel"));
+         return;
+     }
+     m_placesPanel->setFocus(Qt::FocusReason::ShortcutFocusReason);
+     actionCollection()->action(QStringLiteral("focus_places_panel"))->setText(i18nc("@action:inmenu View", "Defocus Places Panel"));
  }
  
  DolphinMainWindow::UndoUiInterface::UndoUiInterface()
@@@ -2899,7 -2951,7 +2952,7 @@@ void DolphinMainWindow::UndoUiInterface
      DolphinMainWindow *mainWin = qobject_cast<DolphinMainWindow *>(parentWidget());
      if (mainWin) {
          DolphinViewContainer *container = mainWin->activeViewContainer();
-         container->showMessage(job->errorString(), DolphinViewContainer::Error);
+         container->showMessage(job->errorString(), KMessageWidget::Error);
      } else {
          KIO::FileUndoManager::UiInterface::jobError(job);
      }
@@@ -2917,7 -2969,10 +2970,10 @@@ bool DolphinMainWindow::isItemVisibleIn
  
  void DolphinMainWindow::slotDoubleClickViewBackground(Qt::MouseButton button)
  {
-     Q_UNUSED(button) // might be of use later
+     if (button == Qt::MouseButton::LeftButton) {
+         // only handle left mouse button for now
+         return;
+     }
  
      GeneralSettings *settings = GeneralSettings::self();
      QString clickAction = settings->doubleClickViewAction();
diff --combined src/dolphinui.rc
index d884fe300476daae672672d836714d38b6bca21c,5c9afa03d0ec0d876bf5e949601bffff124f3e8f..bbbfb967fbf282331012d3f70d1295e497df0645
@@@ -1,6 -1,6 +1,6 @@@
  <?xml version="1.0"?>
  <!DOCTYPE gui SYSTEM "kpartgui.dtd">
- <gui name="dolphin" version="40">
+ <gui name="dolphin" version="41">
      <MenuBar>
          <Menu name="file">
              <Action name="new_menu" />
@@@ -43,7 -43,6 +43,7 @@@
              <Action name="view_zoom_out"/>
              <Separator/>
              <Action name="sort" />
 +            <Action name="group" />
              <Action name="view_mode" />
              <Action name="additional_info" />
              <Action name="show_preview" />
@@@ -74,7 -73,6 +74,6 @@@
              <Action name="open_preferred_search_tool" />
              <Action name="open_terminal" />
              <Action name="open_terminal_here" />
-             <Action name="focus_terminal_panel"/>
              <Action name="compare_files" />
              <Action name="change_remote_encoding" />
          </Menu>
index 5b7b781a8fbdc6d8631b63771b9724f92db7e014,3e4a8c663c1def71aa5aa086562fb3776d0a42be..a6f90b9f5adcf4ad0158ffe43c92b293eb456720
@@@ -34,12 -34,11 +34,12 @@@ Q_GLOBAL_STATIC(QRecursiveMutex, s_coll
  // #define KFILEITEMMODEL_DEBUG
  
  KFileItemModel::KFileItemModel(QObject *parent)
 -    : KItemModelBase("text", parent)
 +    : KItemModelBase("text", "none", parent)
      , m_dirLister(nullptr)
      , m_sortDirsFirst(true)
      , m_sortHiddenLast(false)
      , m_sortRole(NameRole)
 +    , m_groupRole(NoRole)
      , m_sortingProgressPercent(-1)
      , m_roles()
      , m_itemData()
@@@ -388,17 -387,7 +388,17 @@@ QList<QPair<int, QVariant>> KFileItemMo
          QElapsedTimer timer;
          timer.start();
  #endif
 -        switch (typeForRole(sortRole())) {
 +        QByteArray role = groupRole();
 +        if (typeForRole(role) == NoRole) {
 +            // Handle extra grouping information
 +            if (m_groupExtraInfo == "followSort") {
 +                role = sortRole();
 +            }
 +        }
 +        switch (typeForRole(role)) {
 +        case NoRole:
 +            m_groups.clear();
 +            break;
          case NameRole:
              m_groups = nameRoleGroups();
              break;
              m_groups = ratingRoleGroups();
              break;
          default:
 -            m_groups = genericStringRoleGroups(sortRole());
 +            m_groups = genericStringRoleGroups(role);
              break;
          }
  
@@@ -897,39 -886,6 +897,39 @@@ void KFileItemModel::removeFilteredChil
      }
  }
  
 +KFileItemModel::RoleInfo KFileItemModel::roleInformation(const QByteArray &role)
 +{
 +    static QHash<QByteArray, RoleInfo> information;
 +    if (information.isEmpty()) {
 +        int count = 0;
 +        const RoleInfoMap *map = rolesInfoMap(count);
 +        for (int i = 0; i < count; ++i) {
 +            RoleInfo info;
 +            info.role = map[i].role;
 +            info.translation = map[i].roleTranslation.toString();
 +            if (!map[i].groupTranslation.isEmpty()) {
 +                info.group = map[i].groupTranslation.toString();
 +            } else {
 +                // For top level roles, groupTranslation is 0. We must make sure that
 +                // info.group is an empty string then because the code that generates
 +                // menus tries to put the actions into sub menus otherwise.
 +                info.group = QString();
 +            }
 +            info.requiresBaloo = map[i].requiresBaloo;
 +            info.requiresIndexer = map[i].requiresIndexer;
 +            if (!map[i].tooltipTranslation.isEmpty()) {
 +                info.tooltip = map[i].tooltipTranslation.toString();
 +            } else {
 +                info.tooltip = QString();
 +            }
 +
 +            information.insert(map[i].role, info);
 +        }
 +    }
 +
 +    return information.value(role);
 +}
 +
  QList<KFileItemModel::RoleInfo> KFileItemModel::rolesInformation()
  {
      static QList<RoleInfo> rolesInfo;
          const RoleInfoMap *map = rolesInfoMap(count);
          for (int i = 0; i < count; ++i) {
              if (map[i].roleType != NoRole) {
 -                RoleInfo info;
 -                info.role = map[i].role;
 -                info.translation = map[i].roleTranslation.toString();
 -                if (!map[i].groupTranslation.isEmpty()) {
 -                    info.group = map[i].groupTranslation.toString();
 -                } else {
 -                    // For top level roles, groupTranslation is 0. We must make sure that
 -                    // info.group is an empty string then because the code that generates
 -                    // menus tries to put the actions into sub menus otherwise.
 -                    info.group = QString();
 -                }
 -                info.requiresBaloo = map[i].requiresBaloo;
 -                info.requiresIndexer = map[i].requiresIndexer;
 -                if (!map[i].tooltipTranslation.isEmpty()) {
 -                    info.tooltip = map[i].tooltipTranslation.toString();
 -                } else {
 -                    info.tooltip = QString();
 -                }
 +                RoleInfo info = roleInformation(map[i].role);
                  rolesInfo.append(info);
              }
          }
      return rolesInfo;
  }
  
 +QList<KFileItemModel::RoleInfo> KFileItemModel::extraGroupingInformation()
 +{
 +    static QList<RoleInfo> rolesInfo{
 +        {QByteArray("none"),         kli18nc("@label", "No grouping").toString(),       nullptr,     nullptr,     false,      false},
 +        {QByteArray("followSort"),   kli18nc("@label", "Follow sorting").toString(),    nullptr,    nullptr,     false,      false}
 +    };
 +    return rolesInfo;
 +}
 +
  void KFileItemModel::onGroupedSortingChanged(bool current)
  {
      Q_UNUSED(current)
@@@ -966,13 -930,6 +966,13 @@@ void KFileItemModel::onSortRoleChanged(
  {
      Q_UNUSED(previous)
      m_sortRole = typeForRole(current);
 +    if (m_sortRole == NoRole) {
 +        // Requested role not in list of roles. This could
 +        // be used for indicating non-trivial sorting behavior
 +        m_sortExtraInfo = current;
 +    } else {
 +        m_sortExtraInfo.clear();
 +    }
  
      if (!m_requestRole[m_sortRole]) {
          QSet<QByteArray> newRoles = m_roles;
@@@ -992,36 -949,6 +992,36 @@@ void KFileItemModel::onSortOrderChanged
      resortAllItems();
  }
  
 +void KFileItemModel::onGroupRoleChanged(const QByteArray &current, const QByteArray &previous, bool resortItems)
 +{
 +    Q_UNUSED(previous)
 +    m_groupRole = typeForRole(current);
 +    if (m_groupRole == NoRole) {
 +        // Requested role not in list of roles. This could
 +        // be used for indicating non-trivial grouping behavior
 +        m_groupExtraInfo = current;
 +    } else {
 +        m_groupExtraInfo.clear();
 +    }
 +
 +    if (!m_requestRole[m_groupRole]) {
 +        QSet<QByteArray> newRoles = m_roles;
 +        newRoles << current;
 +        setRoles(newRoles);
 +    }
 +
 +    if (resortItems) {
 +        resortAllItems();
 +    }
 +}
 +
 +void KFileItemModel::onGroupOrderChanged(Qt::SortOrder current, Qt::SortOrder previous)
 +{
 +    Q_UNUSED(current)
 +    Q_UNUSED(previous)
 +    resortAllItems();
 +}
 +
  void KFileItemModel::loadSortingSettings()
  {
      using Choice = GeneralSettings::EnumSortingChoice;
      // Workaround for bug https://bugreports.qt.io/browse/QTBUG-69361
      // Force the clean state of QCollator in single thread to avoid thread safety problems in sort
      m_collator.compare(QString(), QString());
 +    ContentDisplaySettings::self();
  }
  
  void KFileItemModel::resortAllItems()
          }
  
          Q_EMIT itemsMoved(KItemRange(firstMovedIndex, movedItemsCount), movedToIndexes);
 -    } else if (groupedSorting()) {
 +    }
 +    if (groupedSorting()) {
          // The groups might have changed even if the order of the items has not.
          const QList<QPair<int, QVariant>> oldGroups = m_groups;
          m_groups.clear();
@@@ -1696,7 -1621,7 +1696,7 @@@ void KFileItemModel::removeItems(const 
  
  QList<KFileItemModel::ItemData *> KFileItemModel::createItemDataList(const QUrl &parentUrl, const KFileItemList &items) const
  {
 -    if (m_sortRole == TypeRole) {
 +    if (m_sortRole == TypeRole || m_groupRole == TypeRole) {
          // Try to resolve the MIME-types synchronously to prevent a reordering of
          // the items when sorting by type (per default MIME-types are resolved
          // asynchronously by KFileItemModelRolesUpdater).
      return itemDataList;
  }
  
 -void KFileItemModel::prepareItemsForSorting(QList<ItemData *> &itemDataList)
 +void KFileItemModel::prepareItemsWithRole(QList<ItemData *> &itemDataList, RoleType roleType)
  {
 -    switch (m_sortRole) {
 +    switch (roleType) {
      case ExtensionRole:
      case PermissionsRole:
      case OwnerRole:
      }
  }
  
 +void KFileItemModel::prepareItemsForSorting(QList<ItemData *> &itemDataList)
 +{
 +    prepareItemsWithRole(itemDataList, m_sortRole);
 +    prepareItemsWithRole(itemDataList, m_groupRole);
 +}
 +
  int KFileItemModel::expandedParentsCount(const ItemData *data)
  {
      // The hash 'values' is only guaranteed to contain the key "expandedParentsCount"
@@@ -2100,33 -2019,31 +2100,33 @@@ bool KFileItemModel::lessThan(const Ite
          }
      }
  
 -    // Show hidden files and folders last
 -    if (m_sortHiddenLast) {
 -        const bool isHiddenA = a->item.isHidden();
 -        const bool isHiddenB = b->item.isHidden();
 -        if (isHiddenA && !isHiddenB) {
 -            return false;
 -        } else if (!isHiddenA && isHiddenB) {
 -            return true;
 +    result = groupRoleCompare(a, b, collator);
 +    if (result == 0) {
 +        // Show hidden files and folders last
 +        if (m_sortHiddenLast) {
 +            const bool isHiddenA = a->item.isHidden();
 +            const bool isHiddenB = b->item.isHidden();
 +            if (isHiddenA && !isHiddenB) {
 +                return false;
 +            } else if (!isHiddenA && isHiddenB) {
 +                return true;
 +            }
          }
 -    }
 -
 -    if (m_sortDirsFirst
 -        || (ContentDisplaySettings::directorySizeMode() == ContentDisplaySettings::EnumDirectorySizeMode::ContentCount && m_sortRole == SizeRole)) {
 -        const bool isDirA = a->item.isDir();
 -        const bool isDirB = b->item.isDir();
 -        if (isDirA && !isDirB) {
 -            return true;
 -        } else if (!isDirA && isDirB) {
 -            return false;
 +        if (m_sortDirsFirst || (ContentDisplaySettings::directorySizeMode() == ContentDisplaySettings::EnumDirectorySizeMode::ContentCount && m_sortRole == SizeRole)) {
 +            const bool isDirA = a->item.isDir();
 +            const bool isDirB = b->item.isDir();
 +            if (isDirA && !isDirB) {
 +                return true;
 +            } else if (!isDirA && isDirB) {
 +                return false;
 +            }
          }
 +        result = sortRoleCompare(a, b, collator);
 +        result = (sortOrder() == Qt::AscendingOrder) ? result < 0 : result > 0;
 +    } else {
 +        result = (groupOrder() == Qt::AscendingOrder) ? result < 0 : result > 0;
      }
 -
 -    result = sortRoleCompare(a, b, collator);
 -
 -    return (sortOrder() == Qt::AscendingOrder) ? result < 0 : result > 0;
 +    return result;
  }
  
  void KFileItemModel::sort(const QList<KFileItemModel::ItemData *>::iterator &begin, const QList<KFileItemModel::ItemData *>::iterator &end) const
@@@ -2317,108 -2234,6 +2317,108 @@@ int KFileItemModel::sortRoleCompare(con
      return QString::compare(itemA.url().url(), itemB.url().url(), Qt::CaseSensitive);
  }
  
 +int KFileItemModel::groupRoleCompare(const ItemData *a, const ItemData *b, const QCollator &collator) const
 +{
 +    // Unlike sortRoleCompare, this function can and often will return 0.
 +    int result = 0;
 +
 +    ItemGroupInfo groupA, groupB;
 +    switch (m_groupRole) {
 +    case NoRole:
 +        // Non-trivial grouping behavior might be handled there in the future.
 +        return 0;
 +    case NameRole:
 +        groupA = nameRoleGroup(a, false);
 +        groupB = nameRoleGroup(b, false);
 +        break;
 +    case SizeRole:
 +        groupA = sizeRoleGroup(a, false);
 +        groupB = sizeRoleGroup(b, false);
 +        break;
 +    case ModificationTimeRole:
 +        groupA = timeRoleGroup(
 +            [](const ItemData *item) {
 +                return item->item.time(KFileItem::ModificationTime);
 +            },
 +            a,
 +            false);
 +        groupB = timeRoleGroup(
 +            [](const ItemData *item) {
 +                return item->item.time(KFileItem::ModificationTime);
 +            },
 +            b,
 +            false);
 +        break;
 +    case CreationTimeRole:
 +        groupA = timeRoleGroup(
 +            [](const ItemData *item) {
 +                return item->item.time(KFileItem::CreationTime);
 +            },
 +            a,
 +            false);
 +        groupB = timeRoleGroup(
 +            [](const ItemData *item) {
 +                return item->item.time(KFileItem::CreationTime);
 +            },
 +            b,
 +            false);
 +        break;
 +    case AccessTimeRole:
 +        groupA = timeRoleGroup(
 +            [](const ItemData *item) {
 +                return item->item.time(KFileItem::AccessTime);
 +            },
 +            a,
 +            false);
 +        groupB = timeRoleGroup(
 +            [](const ItemData *item) {
 +                return item->item.time(KFileItem::AccessTime);
 +            },
 +            b,
 +            false);
 +        break;
 +    case DeletionTimeRole:
 +        groupA = timeRoleGroup(
 +            [](const ItemData *item) {
 +                return item->values.value("deletiontime").toDateTime();
 +            },
 +            a,
 +            false);
 +        groupB = timeRoleGroup(
 +            [](const ItemData *item) {
 +                return item->values.value("deletiontime").toDateTime();
 +            },
 +            b,
 +            false);
 +        break;
 +    case PermissionsRole:
 +        groupA = permissionRoleGroup(a, false);
 +        groupB = permissionRoleGroup(b, false);
 +        break;
 +    case RatingRole:
 +        groupA = ratingRoleGroup(a, false);
 +        groupB = ratingRoleGroup(b, false);
 +        break;
 +    case TypeRole:
 +        groupA = typeRoleGroup(a);
 +        groupB = typeRoleGroup(b);
 +        break;
 +    default: {
 +        groupA = genericStringRoleGroup(groupRole(), a);
 +        groupB = genericStringRoleGroup(groupRole(), b);
 +        break;
 +    }
 +    }
 +    if (groupA.comparable < groupB.comparable) {
 +        result = -1;
 +    } else if (groupA.comparable > groupB.comparable) {
 +        result = 1;
 +    } else {
 +        result = stringCompare(groupA.text, groupB.text, collator);
 +    }
 +    return result;
 +}
 +
  int KFileItemModel::stringCompare(const QString &a, const QString &b, const QCollator &collator) const
  {
      QMutexLocker collatorLock(s_collatorMutex());
      return QString::compare(a, b, Qt::CaseSensitive);
  }
  
 -QList<QPair<int, QVariant>> KFileItemModel::nameRoleGroups() const
 +KFileItemModel::ItemGroupInfo KFileItemModel::nameRoleGroup(const ItemData *itemData, bool withString) const
  {
 -    Q_ASSERT(!m_itemData.isEmpty());
 -
 -    const int maxIndex = count() - 1;
 -    QList<QPair<int, QVariant>> groups;
 -
 -    QString groupValue;
 +    static bool oldWithString;
 +    static ItemGroupInfo oldGroupInfo;
 +    static QChar oldFirstChar;
 +    ItemGroupInfo groupInfo;
      QChar firstChar;
 -    for (int i = 0; i <= maxIndex; ++i) {
 -        if (isChildItem(i)) {
 -            continue;
 -        }
  
 -        const QString name = m_itemData.at(i)->item.text();
 +    const QString name = itemData->item.text();
  
 -        // Use the first character of the name as group indication
 -        QChar newFirstChar = name.at(0).toUpper();
 -        if (newFirstChar == QLatin1Char('~') && name.length() > 1) {
 -            newFirstChar = name.at(1).toUpper();
 -        }
 -
 -        if (firstChar != newFirstChar) {
 -            QString newGroupValue;
 -            if (newFirstChar.isLetter()) {
 -                if (m_collator.compare(newFirstChar, QChar(QLatin1Char('A'))) >= 0 && m_collator.compare(newFirstChar, QChar(QLatin1Char('Z'))) <= 0) {
 -                    // WARNING! Symbols based on latin 'Z' like 'Z' with acute are treated wrong as non Latin and put in a new group.
 -
 -                    // Try to find a matching group in the range 'A' to 'Z'.
 -                    static std::vector<QChar> lettersAtoZ;
 -                    lettersAtoZ.reserve('Z' - 'A' + 1);
 -                    if (lettersAtoZ.empty()) {
 -                        for (char c = 'A'; c <= 'Z'; ++c) {
 -                            lettersAtoZ.push_back(QLatin1Char(c));
 -                        }
 -                    }
 +    QMutexLocker collatorLock(s_collatorMutex());
  
 -                    auto localeAwareLessThan = [this](QChar c1, QChar c2) -> bool {
 -                        return m_collator.compare(c1, c2) < 0;
 -                    };
 +    // Use the first character of the name as group indication
 +    firstChar = name.at(0).toUpper();
 +    
 +    if (firstChar == oldFirstChar && withString == oldWithString) {
 +        return oldGroupInfo;
 +    }
 +    if (firstChar == QLatin1Char('~') && name.length() > 1) {
 +        firstChar = name.at(1).toUpper();
 +    }
 +    if (firstChar.isLetter()) {
 +        if (m_collator.compare(firstChar, QChar(QLatin1Char('A'))) >= 0 && m_collator.compare(firstChar, QChar(QLatin1Char('Z'))) <= 0) {
 +            // WARNING! Symbols based on latin 'Z' like 'Z' with acute are treated wrong as non Latin and put in a new group.
 +
 +            // Try to find a matching group in the range 'A' to 'Z'.
 +            static std::vector<QChar> lettersAtoZ;
 +            lettersAtoZ.reserve('Z' - 'A' + 1);
 +            if (lettersAtoZ.empty()) {
 +                for (char c = 'A'; c <= 'Z'; ++c) {
 +                    lettersAtoZ.push_back(QLatin1Char(c));
 +                }
 +            }
  
 -                    std::vector<QChar>::iterator it = std::lower_bound(lettersAtoZ.begin(), lettersAtoZ.end(), newFirstChar, localeAwareLessThan);
 -                    if (it != lettersAtoZ.end()) {
 -                        if (localeAwareLessThan(newFirstChar, *it)) {
 -                            // newFirstChar belongs to the group preceding *it.
 -                            // Example: for an umlaut 'A' in the German locale, *it would be 'B' now.
 -                            --it;
 -                        }
 -                        newGroupValue = *it;
 -                    }
 +            auto localeAwareLessThan = [this](QChar c1, QChar c2) -> bool {
 +                return m_collator.compare(c1, c2) < 0;
 +            };
  
 -                } else {
 -                    // Symbols from non Latin-based scripts
 -                    newGroupValue = newFirstChar;
 +            std::vector<QChar>::iterator it = std::lower_bound(lettersAtoZ.begin(), lettersAtoZ.end(), firstChar, localeAwareLessThan);
 +            if (it != lettersAtoZ.end()) {
 +                if (localeAwareLessThan(firstChar, *it)) {
 +                    // newFirstChar belongs to the group preceding *it.
 +                    // Example: for an umlaut 'A' in the German locale, *it would be 'B' now.
 +                    --it;
                  }
 -            } else if (newFirstChar >= QLatin1Char('0') && newFirstChar <= QLatin1Char('9')) {
 -                // Apply group '0 - 9' for any name that starts with a digit
 -                newGroupValue = i18nc("@title:group Groups that start with a digit", "0 - 9");
 -            } else {
 -                newGroupValue = i18nc("@title:group", "Others");
 +                if (withString) {
 +                    groupInfo.text = *it;
 +                }
 +                groupInfo.comparable = (*it).unicode();
              }
  
 -            if (newGroupValue != groupValue) {
 -                groupValue = newGroupValue;
 -                groups.append(QPair<int, QVariant>(i, newGroupValue));
 +        } else {
 +            // Symbols from non Latin-based scripts
 +            if (withString) {
 +                groupInfo.text = firstChar;
              }
 -
 -            firstChar = newFirstChar;
 +            groupInfo.comparable = firstChar.unicode();
 +        }
 +    } else if (firstChar >= QLatin1Char('0') && firstChar <= QLatin1Char('9')) {
 +        // Apply group '0 - 9' for any name that starts with a digit
 +        if (withString) {
 +            groupInfo.text = i18nc("@title:group Groups that start with a digit", "0 - 9");
          }
 +        groupInfo.comparable = (int)'0';
 +    } else {
 +        if (withString) {
 +            groupInfo.text = i18nc("@title:group", "Others");
 +        }
 +        groupInfo.comparable = (int)'.';
      }
 -    return groups;
 +    oldWithString = withString;
 +    oldFirstChar = firstChar;
 +    oldGroupInfo = groupInfo;
 +    return groupInfo;
  }
  
 -QList<QPair<int, QVariant>> KFileItemModel::sizeRoleGroups() const
 +KFileItemModel::ItemGroupInfo KFileItemModel::sizeRoleGroup(const ItemData *itemData, bool withString) const
  {
 -    Q_ASSERT(!m_itemData.isEmpty());
 +    ItemGroupInfo groupInfo;
 +    KIO::filesize_t fileSize;
  
 -    const int maxIndex = count() - 1;
 -    QList<QPair<int, QVariant>> groups;
 +    const KFileItem item = itemData->item;
 +    fileSize = !item.isNull() ? item.size() : ~0U;
  
 -    QString groupValue;
 -    for (int i = 0; i <= maxIndex; ++i) {
 -        if (isChildItem(i)) {
 -            continue;
 -        }
 -
 -        const KFileItem &item = m_itemData.at(i)->item;
 -        KIO::filesize_t fileSize = !item.isNull() ? item.size() : ~0U;
 -        QString newGroupValue;
 -        if (!item.isNull() && item.isDir()) {
 -            if (ContentDisplaySettings::directorySizeMode() == ContentDisplaySettings::EnumDirectorySizeMode::ContentCount || m_sortDirsFirst) {
 -                newGroupValue = i18nc("@title:group Size", "Folders");
 -            } else {
 -                fileSize = m_itemData.at(i)->values.value("size").toULongLong();
 -            }
 -        }
 -
 -        if (newGroupValue.isEmpty()) {
 -            if (fileSize < 5 * 1024 * 1024) { // < 5 MB
 -                newGroupValue = i18nc("@title:group Size", "Small");
 -            } else if (fileSize < 10 * 1024 * 1024) { // < 10 MB
 -                newGroupValue = i18nc("@title:group Size", "Medium");
 -            } else {
 -                newGroupValue = i18nc("@title:group Size", "Big");
 -            }
 +    groupInfo.comparable = -1; // None
 +    if (!item.isNull() && item.isDir()) {
 +        if (ContentDisplaySettings::directorySizeMode() != ContentDisplaySettings::EnumDirectorySizeMode::ContentSize) {
 +            groupInfo.comparable = 0; // Folders
 +        } else {
 +            fileSize = itemData->values.value("size").toULongLong();
          }
 -
 -        if (newGroupValue != groupValue) {
 -            groupValue = newGroupValue;
 -            groups.append(QPair<int, QVariant>(i, newGroupValue));
 +    }
 +    if (groupInfo.comparable < 0) {
 +        if (fileSize < 5 * 1024 * 1024) { // < 5 MB
 +            groupInfo.comparable = 1; // Small
 +        } else if (fileSize < 10 * 1024 * 1024) { // < 10 MB
 +            groupInfo.comparable = 2; // Medium
 +        } else {
 +            groupInfo.comparable = 3; // Big
          }
      }
  
 -    return groups;
 +    if (withString) {
 +        char const *groupNames[] = {"Folders", "Small", "Medium", "Big"};
 +        groupInfo.text = i18nc("@title:group Size", groupNames[groupInfo.comparable]);
 +    }
 +    return groupInfo;
  }
  
 -QList<QPair<int, QVariant>> KFileItemModel::timeRoleGroups(const std::function<QDateTime(const ItemData *)> &fileTimeCb) const
 +KFileItemModel::ItemGroupInfo
 +KFileItemModel::timeRoleGroup(const std::function<QDateTime(const ItemData *)> &fileTimeCb, const ItemData *itemData, bool withString) const
  {
 -    Q_ASSERT(!m_itemData.isEmpty());
 -
 -    const int maxIndex = count() - 1;
 -    QList<QPair<int, QVariant>> groups;
 +    static bool oldWithString;
 +    static ItemGroupInfo oldGroupInfo;
 +    static QDate oldFileDate;
 +    ItemGroupInfo groupInfo;
  
      const QDate currentDate = QDate::currentDate();
-     const QDateTime fileTime = fileTimeCb(itemData);
-     const QDate fileDate = fileTime.date();
-     const int daysDistance = fileDate.daysTo(currentDate);
+     QDate previousFileDate;
+     QString groupValue;
+     for (int i = 0; i <= maxIndex; ++i) {
+         if (isChildItem(i)) {
+             continue;
+         }
+         const QLocale locale;
+         const QDateTime fileTime = fileTimeCb(m_itemData.at(i));
+         const QDate fileDate = fileTime.date();
+         if (fileDate == previousFileDate) {
+             // The current item is in the same group as the previous item
+             continue;
+         }
+         previousFileDate = fileDate;
+         const int daysDistance = fileDate.daysTo(currentDate);
  
 -        QString newGroupValue;
 +    if (fileDate == oldFileDate && withString == oldWithString) {
 +        return oldGroupInfo;
 +    }
 +    // Simplified grouping algorithm, preserving dates
 +    // but not taking "pretty printing" into account
 +    if (currentDate.year() == fileDate.year() && currentDate.month() == fileDate.month()) {
 +        if (daysDistance < 7) {
 +            groupInfo.comparable = daysDistance; // Today, Yesterday and week days
 +        } else if (daysDistance < 14) {
 +            groupInfo.comparable = 10; // One Week Ago
 +        } else if (daysDistance < 21) {
 +            groupInfo.comparable = 20; // Two Weeks Ago
 +        } else if (daysDistance < 28) {
 +            groupInfo.comparable = 30; // Three Weeks Ago
 +        } else {
 +            groupInfo.comparable = 40; // Earlier This Month
 +        }
 +    } else {
 +        const QDate lastMonthDate = currentDate.addMonths(-1);
 +        if (lastMonthDate.year() == fileDate.year() && lastMonthDate.month() == fileDate.month()) {
 +            if (daysDistance < 7) {
 +                groupInfo.comparable = daysDistance; // Today, Yesterday and week days (Month, Year)
 +            } else if (daysDistance < 14) {
 +                groupInfo.comparable = 11; // One Week Ago (Month, Year)
 +            } else if (daysDistance < 21) {
 +                groupInfo.comparable = 21; // Two Weeks Ago (Month, Year)
 +            } else if (daysDistance < 28) {
 +                groupInfo.comparable = 31; // Three Weeks Ago (Month, Year)
 +            } else {
 +                groupInfo.comparable = 41; // Earlier on Month, Year
 +            }
 +        } else {
 +            // The trick will fail for dates past April, 178956967 or before 1 AD.
 +            groupInfo.comparable = 2147483647 - (fileDate.year() * 12 + fileDate.month() - 1); // Month, Year; newer < older
 +        }
 +    }
 +    if (withString) {
          if (currentDate.year() == fileDate.year() && currentDate.month() == fileDate.month()) {
              switch (daysDistance / 7) {
              case 0:
                  switch (daysDistance) {
                  case 0:
 -                    newGroupValue = i18nc("@title:group Date", "Today");
 +                    groupInfo.text = i18nc("@title:group Date", "Today");
                      break;
                  case 1:
 -                    newGroupValue = i18nc("@title:group Date", "Yesterday");
 +                    groupInfo.text = i18nc("@title:group Date", "Yesterday");
                      break;
                  default:
-                     groupInfo.text = fileTime.toString(i18nc("@title:group Date: The week day name: dddd", "dddd"));
-                     groupInfo.text = i18nc(
+                     newGroupValue = locale.toString(fileTime, i18nc("@title:group Date: The week day name: dddd", "dddd"));
+                     newGroupValue = i18nc(
                          "Can be used to script translation of \"dddd\""
                          "with context @title:group Date",
                          "%1",
 -                        newGroupValue);
 +                        groupInfo.text);
                  }
                  break;
              case 1:
 -                newGroupValue = i18nc("@title:group Date", "One Week Ago");
 +                groupInfo.text = i18nc("@title:group Date", "One Week Ago");
                  break;
              case 2:
 -                newGroupValue = i18nc("@title:group Date", "Two Weeks Ago");
 +                groupInfo.text = i18nc("@title:group Date", "Two Weeks Ago");
                  break;
              case 3:
 -                newGroupValue = i18nc("@title:group Date", "Three Weeks Ago");
 +                groupInfo.text = i18nc("@title:group Date", "Three Weeks Ago");
                  break;
              case 4:
              case 5:
 -                newGroupValue = i18nc("@title:group Date", "Earlier this Month");
 +                groupInfo.text = i18nc("@title:group Date", "Earlier this Month");
                  break;
              default:
                  Q_ASSERT(false);
                          "'Yesterday' (MMMM, yyyy)");
                      const QString translatedFormat = format.toString();
                      if (translatedFormat.count(QLatin1Char('\'')) == 2) {
-                         groupInfo.text = fileTime.toString(translatedFormat);
-                         groupInfo.text = i18nc(
+                         newGroupValue = locale.toString(fileTime, translatedFormat);
+                         newGroupValue = i18nc(
                              "Can be used to script translation of "
                              "\"'Yesterday' (MMMM, yyyy)\" with context @title:group Date",
                              "%1",
 -                            newGroupValue);
 +                            groupInfo.text);
                      } else {
                          qCWarning(DolphinDebug).nospace()
                              << "A wrong translation was found: " << translatedFormat << ". Please file a bug report at bugs.kde.org";
                          const QString untranslatedFormat = format.toString({QLatin1String("en_US")});
-                         groupInfo.text = fileTime.toString(untranslatedFormat);
+                         newGroupValue = locale.toString(fileTime, untranslatedFormat);
                      }
-                 } else if (daysDistance < 7) {
-                     groupInfo.text =
-                         fileTime.toString(i18nc("@title:group Date: "
-                                                 "The week day name: dddd, MMMM is full month name "
-                                                 "in current locale, and yyyy is full year number.",
-                                                 "dddd (MMMM, yyyy)"));
-                     groupInfo.text = i18nc(
+                 } else if (daysDistance <= 7) {
+                     newGroupValue = locale.toString(fileTime,
+                                                     i18nc("@title:group Date: "
+                                                           "The week day name: dddd, MMMM is full month name "
+                                                           "in current locale, and yyyy is full year number.",
+                                                           "dddd (MMMM, yyyy)"));
+                     newGroupValue = i18nc(
                          "Can be used to script translation of "
                          "\"dddd (MMMM, yyyy)\" with context @title:group Date",
                          "%1",
 -                        newGroupValue);
 -                } else if (daysDistance <= 7 * 2) {
 +                        groupInfo.text);
 +                } else if (daysDistance < 7 * 2) {
                      const KLocalizedString format = ki18nc(
                          "@title:group Date: "
                          "MMMM is full month name in current locale, and yyyy is "
                          "'One Week Ago' (MMMM, yyyy)");
                      const QString translatedFormat = format.toString();
                      if (translatedFormat.count(QLatin1Char('\'')) == 2) {
-                         groupInfo.text = fileTime.toString(translatedFormat);
-                         groupInfo.text = i18nc(
+                         newGroupValue = locale.toString(fileTime, translatedFormat);
+                         newGroupValue = i18nc(
                              "Can be used to script translation of "
                              "\"'One Week Ago' (MMMM, yyyy)\" with context @title:group Date",
                              "%1",
 -                            newGroupValue);
 +                            groupInfo.text);
                      } else {
                          qCWarning(DolphinDebug).nospace()
                              << "A wrong translation was found: " << translatedFormat << ". Please file a bug report at bugs.kde.org";
                          const QString untranslatedFormat = format.toString({QLatin1String("en_US")});
-                         groupInfo.text = fileTime.toString(untranslatedFormat);
+                         newGroupValue = locale.toString(fileTime, untranslatedFormat);
                      }
 -                } else if (daysDistance <= 7 * 3) {
 +                } else if (daysDistance < 7 * 3) {
                      const KLocalizedString format = ki18nc(
                          "@title:group Date: "
                          "MMMM is full month name in current locale, and yyyy is "
                          "'Two Weeks Ago' (MMMM, yyyy)");
                      const QString translatedFormat = format.toString();
                      if (translatedFormat.count(QLatin1Char('\'')) == 2) {
-                         groupInfo.text = fileTime.toString(translatedFormat);
-                         groupInfo.text = i18nc(
+                         newGroupValue = locale.toString(fileTime, translatedFormat);
+                         newGroupValue = i18nc(
                              "Can be used to script translation of "
                              "\"'Two Weeks Ago' (MMMM, yyyy)\" with context @title:group Date",
                              "%1",
 -                            newGroupValue);
 +                            groupInfo.text);
                      } else {
                          qCWarning(DolphinDebug).nospace()
                              << "A wrong translation was found: " << translatedFormat << ". Please file a bug report at bugs.kde.org";
                          const QString untranslatedFormat = format.toString({QLatin1String("en_US")});
-                         groupInfo.text = fileTime.toString(untranslatedFormat);
+                         newGroupValue = locale.toString(fileTime, untranslatedFormat);
                      }
 -                } else if (daysDistance <= 7 * 4) {
 +                } else if (daysDistance < 7 * 4) {
                      const KLocalizedString format = ki18nc(
                          "@title:group Date: "
                          "MMMM is full month name in current locale, and yyyy is "
                          "'Three Weeks Ago' (MMMM, yyyy)");
                      const QString translatedFormat = format.toString();
                      if (translatedFormat.count(QLatin1Char('\'')) == 2) {
-                         groupInfo.text = fileTime.toString(translatedFormat);
-                         groupInfo.text = i18nc(
+                         newGroupValue = locale.toString(fileTime, translatedFormat);
+                         newGroupValue = i18nc(
                              "Can be used to script translation of "
                              "\"'Three Weeks Ago' (MMMM, yyyy)\" with context @title:group Date",
                              "%1",
 -                            newGroupValue);
 +                            groupInfo.text);
                      } else {
                          qCWarning(DolphinDebug).nospace()
                              << "A wrong translation was found: " << translatedFormat << ". Please file a bug report at bugs.kde.org";
                          const QString untranslatedFormat = format.toString({QLatin1String("en_US")});
-                         groupInfo.text = fileTime.toString(untranslatedFormat);
+                         newGroupValue = locale.toString(fileTime, untranslatedFormat);
                      }
                  } else {
                      const KLocalizedString format = ki18nc(
                          "'Earlier on' MMMM, yyyy");
                      const QString translatedFormat = format.toString();
                      if (translatedFormat.count(QLatin1Char('\'')) == 2) {
-                         groupInfo.text = fileTime.toString(translatedFormat);
-                         groupInfo.text = i18nc(
+                         newGroupValue = locale.toString(fileTime, translatedFormat);
+                         newGroupValue = i18nc(
                              "Can be used to script translation of "
                              "\"'Earlier on' MMMM, yyyy\" with context @title:group Date",
                              "%1",
 -                            newGroupValue);
 +                            groupInfo.text);
                      } else {
                          qCWarning(DolphinDebug).nospace()
                              << "A wrong translation was found: " << translatedFormat << ". Please file a bug report at bugs.kde.org";
                          const QString untranslatedFormat = format.toString({QLatin1String("en_US")});
-                         groupInfo.text = fileTime.toString(untranslatedFormat);
+                         newGroupValue = locale.toString(fileTime, untranslatedFormat);
                      }
                  }
              } else {
-                 groupInfo.text =
-                     fileTime.toString(i18nc("@title:group "
-                                             "The month and year: MMMM is full month name in current locale, "
-                                             "and yyyy is full year number",
-                                             "MMMM, yyyy"));
-                 groupInfo.text = i18nc(
+                 newGroupValue = locale.toString(fileTime,
+                                                 i18nc("@title:group "
+                                                       "The month and year: MMMM is full month name in current locale, "
+                                                       "and yyyy is full year number",
+                                                       "MMMM, yyyy"));
+                 newGroupValue = i18nc(
                      "Can be used to script translation of "
                      "\"MMMM, yyyy\" with context @title:group Date",
                      "%1",
 -                    newGroupValue);
 +                    groupInfo.text);
              }
          }
 -
 -        if (newGroupValue != groupValue) {
 -            groupValue = newGroupValue;
 -            groups.append(QPair<int, QVariant>(i, newGroupValue));
 -        }
      }
 -
 -    return groups;
 +    oldWithString = withString;
 +    oldFileDate = fileDate;
 +    oldGroupInfo = groupInfo;
 +    return groupInfo;
  }
  
 -QList<QPair<int, QVariant>> KFileItemModel::permissionRoleGroups() const
 +KFileItemModel::ItemGroupInfo KFileItemModel::permissionRoleGroup(const ItemData *itemData, bool withString) const
  {
 -    Q_ASSERT(!m_itemData.isEmpty());
 +    static bool oldWithString;
 +    static ItemGroupInfo oldGroupInfo;
 +    static QFileDevice::Permissions oldPermissions;
 +    ItemGroupInfo groupInfo;
  
 -    const int maxIndex = count() - 1;
 -    QList<QPair<int, QVariant>> groups;
 -
 -    QString permissionsString;
 -    QString groupValue;
 -    for (int i = 0; i <= maxIndex; ++i) {
 -        if (isChildItem(i)) {
 -            continue;
 -        }
 -
 -        const ItemData *itemData = m_itemData.at(i);
 -        const QString newPermissionsString = itemData->values.value("permissions").toString();
 -        if (newPermissionsString == permissionsString) {
 -            continue;
 -        }
 -        permissionsString = newPermissionsString;
 -
 -        const QFileInfo info(itemData->item.url().toLocalFile());
 +    const QFileInfo info(itemData->item.url().toLocalFile());
 +    const QFileDevice::Permissions permissions = info.permissions();
 +    if (permissions == oldPermissions && withString == oldWithString) {
 +        return oldGroupInfo;
 +    }
 +    groupInfo.comparable = (int)permissions;
  
 +    if (withString) {
          // Set user string
          QString user;
 -        if (info.permission(QFile::ReadUser)) {
 +        if (permissions & QFile::ReadUser) {
              user = i18nc("@item:intext Access permission, concatenated", "Read, ");
          }
 -        if (info.permission(QFile::WriteUser)) {
 +        if (permissions & QFile::WriteUser) {
              user += i18nc("@item:intext Access permission, concatenated", "Write, ");
          }
 -        if (info.permission(QFile::ExeUser)) {
 +        if (permissions & QFile::ExeUser) {
              user += i18nc("@item:intext Access permission, concatenated", "Execute, ");
          }
          user = user.isEmpty() ? i18nc("@item:intext Access permission, concatenated", "Forbidden") : user.mid(0, user.length() - 2);
  
          // Set group string
          QString group;
 -        if (info.permission(QFile::ReadGroup)) {
 +        if (permissions & QFile::ReadGroup) {
              group = i18nc("@item:intext Access permission, concatenated", "Read, ");
          }
 -        if (info.permission(QFile::WriteGroup)) {
 +        if (permissions & QFile::WriteGroup) {
              group += i18nc("@item:intext Access permission, concatenated", "Write, ");
          }
 -        if (info.permission(QFile::ExeGroup)) {
 +        if (permissions & QFile::ExeGroup) {
              group += i18nc("@item:intext Access permission, concatenated", "Execute, ");
          }
          group = group.isEmpty() ? i18nc("@item:intext Access permission, concatenated", "Forbidden") : group.mid(0, group.length() - 2);
  
          // Set others string
          QString others;
 -        if (info.permission(QFile::ReadOther)) {
 +        if (permissions & QFile::ReadOther) {
              others = i18nc("@item:intext Access permission, concatenated", "Read, ");
          }
 -        if (info.permission(QFile::WriteOther)) {
 +        if (permissions & QFile::WriteOther) {
              others += i18nc("@item:intext Access permission, concatenated", "Write, ");
          }
 -        if (info.permission(QFile::ExeOther)) {
 +        if (permissions & QFile::ExeOther) {
              others += i18nc("@item:intext Access permission, concatenated", "Execute, ");
          }
          others = others.isEmpty() ? i18nc("@item:intext Access permission, concatenated", "Forbidden") : others.mid(0, others.length() - 2);
 +        groupInfo.text = i18nc("@title:group Files and folders by permissions", "User: %1 | Group: %2 | Others: %3", user, group, others);
 +    }
 +    oldWithString = withString;
 +    oldPermissions = permissions;
 +    oldGroupInfo = groupInfo;
 +    return groupInfo;
 +}
 +
 +KFileItemModel::ItemGroupInfo KFileItemModel::ratingRoleGroup(const ItemData *itemData, bool withString) const
 +{
 +    ItemGroupInfo groupInfo;
 +    groupInfo.comparable = itemData->values.value("rating", 0).toInt();
 +    if (withString) {
 +        // Dolphin does not currently use string representation of star rating
 +        // as stars are rendered as graphics in group headers.
 +        groupInfo.text = i18nc("@item:intext Rated N (stars)", "Rated %i", QString::number(groupInfo.comparable));
 +    }
 +    return groupInfo;
 +}
 +
 +KFileItemModel::ItemGroupInfo KFileItemModel::genericStringRoleGroup(const QByteArray &role, const ItemData *itemData) const
 +{
 +    return {0, itemData->values.value(role).toString()};
 +}
 +
 +QList<QPair<int, QVariant>> KFileItemModel::nameRoleGroups() const
 +{
 +    Q_ASSERT(!m_itemData.isEmpty());
 +
 +    const int maxIndex = count() - 1;
 +    QList<QPair<int, QVariant>> groups;
 +
 +    ItemGroupInfo groupInfo;
 +    for (int i = 0; i <= maxIndex; ++i) {
 +        if (isChildItem(i)) {
 +            continue;
 +        }
 +
 +        ItemGroupInfo newGroupInfo = nameRoleGroup(m_itemData.at(i));
  
 -        const QString newGroupValue = i18nc("@title:group Files and folders by permissions", "User: %1 | Group: %2 | Others: %3", user, group, others);
 -        if (newGroupValue != groupValue) {
 -            groupValue = newGroupValue;
 -            groups.append(QPair<int, QVariant>(i, newGroupValue));
 +        if (newGroupInfo != groupInfo) {
 +            groupInfo = newGroupInfo;
 +            groups.append(QPair<int, QVariant>(i, newGroupInfo.text));
          }
      }
 +    return groups;
 +}
 +
 +QList<QPair<int, QVariant>> KFileItemModel::sizeRoleGroups() const
 +{
 +    Q_ASSERT(!m_itemData.isEmpty());
 +
 +    const int maxIndex = count() - 1;
 +    QList<QPair<int, QVariant>> groups;
 +
 +    ItemGroupInfo groupInfo;
 +    for (int i = 0; i <= maxIndex; ++i) {
 +        if (isChildItem(i)) {
 +            continue;
 +        }
 +
 +        ItemGroupInfo newGroupInfo = sizeRoleGroup(m_itemData.at(i));
  
 +        if (newGroupInfo != groupInfo) {
 +            groupInfo = newGroupInfo;
 +            groups.append(QPair<int, QVariant>(i, newGroupInfo.text));
 +        }
 +    }
      return groups;
  }
  
 -QList<QPair<int, QVariant>> KFileItemModel::ratingRoleGroups() const
 +KFileItemModel::ItemGroupInfo KFileItemModel::typeRoleGroup(const ItemData *itemData) const
 +{
 +    int priority = 0;
 +    if (itemData->item.isDir() && m_sortDirsFirst) {
 +        // Ensure folders stay first regardless of grouping order
 +        if (groupOrder() == Qt::AscendingOrder) {
 +            priority = -1;
 +        } else {
 +            priority = 1;
 +        }
 +    }
 +    return {priority, itemData->values.value("type").toString()};
 +}
 +
 +QList<QPair<int, QVariant>> KFileItemModel::timeRoleGroups(const std::function<QDateTime(const ItemData *)> &fileTimeCb) const
  {
      Q_ASSERT(!m_itemData.isEmpty());
  
      const int maxIndex = count() - 1;
      QList<QPair<int, QVariant>> groups;
  
 -    int groupValue = -1;
 +    ItemGroupInfo groupInfo;
      for (int i = 0; i <= maxIndex; ++i) {
          if (isChildItem(i)) {
              continue;
          }
 -        const int newGroupValue = m_itemData.at(i)->values.value("rating", 0).toInt();
 -        if (newGroupValue != groupValue) {
 -            groupValue = newGroupValue;
 -            groups.append(QPair<int, QVariant>(i, newGroupValue));
 +
 +        ItemGroupInfo newGroupInfo = timeRoleGroup(fileTimeCb, m_itemData.at(i));
 +
 +        if (newGroupInfo != groupInfo) {
 +            groupInfo = newGroupInfo;
 +            groups.append(QPair<int, QVariant>(i, newGroupInfo.text));
          }
      }
 +    return groups;
 +}
  
 +QList<QPair<int, QVariant>> KFileItemModel::permissionRoleGroups() const
 +{
 +    Q_ASSERT(!m_itemData.isEmpty());
 +
 +    const int maxIndex = count() - 1;
 +    QList<QPair<int, QVariant>> groups;
 +
 +    ItemGroupInfo groupInfo;
 +    for (int i = 0; i <= maxIndex; ++i) {
 +        if (isChildItem(i)) {
 +            continue;
 +        }
 +
 +        ItemGroupInfo newGroupInfo = permissionRoleGroup(m_itemData.at(i));
 +
 +        if (newGroupInfo != groupInfo) {
 +            groupInfo = newGroupInfo;
 +            groups.append(QPair<int, QVariant>(i, newGroupInfo.text));
 +        }
 +    }
      return groups;
  }
  
 -QList<QPair<int, QVariant>> KFileItemModel::genericStringRoleGroups(const QByteArray &role) const
 +QList<QPair<int, QVariant>> KFileItemModel::ratingRoleGroups() const
  {
      Q_ASSERT(!m_itemData.isEmpty());
  
      const int maxIndex = count() - 1;
      QList<QPair<int, QVariant>> groups;
  
 -    bool isFirstGroupValue = true;
 -    QString groupValue;
 +    ItemGroupInfo groupInfo;
      for (int i = 0; i <= maxIndex; ++i) {
          if (isChildItem(i)) {
              continue;
          }
 -        const QString newGroupValue = m_itemData.at(i)->values.value(role).toString();
 -        if (newGroupValue != groupValue || isFirstGroupValue) {
 -            groupValue = newGroupValue;
 -            groups.append(QPair<int, QVariant>(i, newGroupValue));
 -            isFirstGroupValue = false;
 +
 +        ItemGroupInfo newGroupInfo = ratingRoleGroup(m_itemData.at(i));
 +
 +        if (newGroupInfo != groupInfo) {
 +            groupInfo = newGroupInfo;
 +            // Using the numeric representation because Dolphin has a special
 +            // case for drawing stars.
 +            groups.append(QPair<int, QVariant>(i, newGroupInfo.comparable));
          }
      }
 +    return groups;
 +}
 +
 +QList<QPair<int, QVariant>> KFileItemModel::genericStringRoleGroups(const QByteArray &role) const
 +{
 +    Q_ASSERT(!m_itemData.isEmpty());
 +
 +    const int maxIndex = count() - 1;
 +    QList<QPair<int, QVariant>> groups;
  
 +    ItemGroupInfo groupInfo;
 +    for (int i = 0; i <= maxIndex; ++i) {
 +        if (isChildItem(i)) {
 +            continue;
 +        }
 +
 +        ItemGroupInfo newGroupInfo = genericStringRoleGroup(role, m_itemData.at(i));
 +
 +        if (newGroupInfo != groupInfo) {
 +            groupInfo = newGroupInfo;
 +            groups.append(QPair<int, QVariant>(i, newGroupInfo.text));
 +        }
 +    }
      return groups;
  }
  
@@@ -3037,7 -2726,7 +3052,7 @@@ const KFileItemModel::RoleInfoMap *KFil
      static const RoleInfoMap rolesInfoMap[] = {
          // clang-format off
      //  |         role           |        roleType        |                role translation          |         group translation                                        | requires Baloo | requires indexer
 -        { nullptr,               NoRole,                  KLazyLocalizedString(),                    KLazyLocalizedString(),        KLazyLocalizedString(),                    false,           false },
 +        { nullptr,               NoRole,                  kli18nc("@label", "None"),                 KLazyLocalizedString(),        KLazyLocalizedString(),                    false,           false },
          { "text",                NameRole,                kli18nc("@label", "Name"),                 KLazyLocalizedString(),        KLazyLocalizedString(),                    false,           false },
          { "size",                SizeRole,                kli18nc("@label", "Size"),                 KLazyLocalizedString(),        KLazyLocalizedString(),                    false,           false },
          { "modificationtime",    ModificationTimeRole,    kli18nc("@label", "Modified"),             KLazyLocalizedString(),        kli18nc("@tooltip", "The date format can be selected in settings."),                    false,           false },
@@@ -3168,13 -2857,14 +3183,14 @@@ bool KFileItemModel::isConsistent() con
  
  void KFileItemModel::slotListerError(KIO::Job *job)
  {
-     if (job->error() == KIO::ERR_IS_FILE) {
+     const int jobError = job->error();
+     if (jobError == KIO::ERR_IS_FILE) {
          if (auto *listJob = qobject_cast<KIO::ListJob *>(job)) {
              Q_EMIT urlIsFileError(listJob->url());
          }
      } else {
          const QString errorString = job->errorString();
-         Q_EMIT errorMessage(!errorString.isEmpty() ? errorString : i18nc("@info:status", "Unknown error."));
+         Q_EMIT errorMessage(!errorString.isEmpty() ? errorString : i18nc("@info:status", "Unknown error."), jobError);
      }
  }
  
index 001c8470129a01bb7da8d2d94025194bd49f9450,5662d4fa8b00eda322e02caf000288bd42848b74..980efe66b26c1108a06996c36683d4d75ed83a50
@@@ -196,13 -196,6 +196,13 @@@ public
          bool requiresIndexer;
      };
  
 +    /**
 +     * @return Provides static information for a role that is supported
 +     *         by KFileItemModel. Some roles can only be determined if
 +     *         Baloo is enabled and/or the Baloo indexing is enabled.
 +     */
 +    static RoleInfo roleInformation(const QByteArray &role);
 +
      /**
       * @return Provides static information for all available roles that
       *         are supported by KFileItemModel. Some roles can only be
       */
      static QList<RoleInfo> rolesInformation();
  
 +    /**
 +     * @return Provides static information for all available grouping
 +     *         behaviors supported by KFileItemModel but not directly
 +     *         mapped to roles of KFileItemModel.
 +     */
 +    static QList<RoleInfo> extraGroupingInformation();
 +
      /** set to true to hide application/x-trash files */
      void setShowTrashMime(bool show);
  
@@@ -272,7 -258,7 +272,7 @@@ Q_SIGNALS
       * Is emitted if an error message (e.g. "Unknown location")
       * should be shown.
       */
-     void errorMessage(const QString &message);
+     void errorMessage(const QString &message, const int kioErrorCode);
  
      /**
       * Is emitted if a redirection from the current URL \a oldUrl
@@@ -301,13 -287,11 +301,13 @@@ protected
      void onGroupedSortingChanged(bool current) override;
      void onSortRoleChanged(const QByteArray &current, const QByteArray &previous, bool resortItems = true) override;
      void onSortOrderChanged(Qt::SortOrder current, Qt::SortOrder previous) override;
 +    void onGroupRoleChanged(const QByteArray &current, const QByteArray &previous, bool resortItems = true) override;
 +    void onGroupOrderChanged(Qt::SortOrder current, Qt::SortOrder previous) override;
  
  private Q_SLOTS:
      /**
 -     * Resorts all items dependent on the set sortRole(), sortOrder()
 -     * and foldersFirst() settings.
 +     * Resorts all items dependent on the set sortRole(), sortOrder(),
 +     * groupRole(), groupOrder() and foldersFirst() settings.
       */
      void resortAllItems();
  
@@@ -381,15 -365,6 +381,15 @@@ private
          ItemData *parent;
      };
  
 +    struct ItemGroupInfo {
 +        int comparable;
 +        QString text;
 +
 +        bool operator==(const ItemGroupInfo &other) const;
 +        bool operator!=(const ItemGroupInfo &other) const;
 +        bool operator<(const ItemGroupInfo &other) const;
 +    };
 +
      enum RemoveItemsBehavior { KeepItemData, DeleteItemData, DeleteItemDataIfUnfiltered };
  
      void insertItems(QList<ItemData *> &items);
       */
      QList<ItemData *> createItemDataList(const QUrl &parentUrl, const KFileItemList &items) const;
  
 +    /**
 +     * Helper method for prepareItemsForSorting(). 
 +     * For a set role, fills 'values' of ItemData non-lazily.
 +     */
 +    void prepareItemsWithRole(QList<ItemData *> &itemDataList, RoleType roleType);
 +
      /**
       * Prepares the items for sorting. Normally, the hash 'values' in ItemData is filled
       * lazily to save time and memory, but for some sort roles, it is expected that the
       */
      int sortRoleCompare(const ItemData *a, const ItemData *b, const QCollator &collator) const;
  
 +    /**
 +     * Helper method for lessThan() and expandedParentsCountCompare(): Compares
 +     * the passed item-data using m_groupRole as criteria. Both items must
 +     * have the same parent item, otherwise the comparison will be wrong.
 +     */
 +    int groupRoleCompare(const ItemData *a, const ItemData *b, const QCollator &collator) const;
 +
      int stringCompare(const QString &a, const QString &b, const QCollator &collator) const;
  
 +    ItemGroupInfo nameRoleGroup(const ItemData *itemData, bool withString = true) const;
 +    ItemGroupInfo sizeRoleGroup(const ItemData *itemData, bool withString = true) const;
 +    ItemGroupInfo timeRoleGroup(const std::function<QDateTime(const ItemData *)> &fileTimeCb, const ItemData *itemData, bool withString = true) const;
 +    ItemGroupInfo permissionRoleGroup(const ItemData *itemData, bool withString = true) const;
 +    ItemGroupInfo ratingRoleGroup(const ItemData *itemData, bool withString = true) const;
 +    ItemGroupInfo typeRoleGroup(const ItemData *itemData) const;
 +    ItemGroupInfo genericStringRoleGroup(const QByteArray &role, const ItemData *itemData) const;
 +
      QList<QPair<int, QVariant>> nameRoleGroups() const;
      QList<QPair<int, QVariant>> sizeRoleGroups() const;
      QList<QPair<int, QVariant>> timeRoleGroups(const std::function<QDateTime(const ItemData *)> &fileTimeCb) const;
      QList<QPair<int, QVariant>> permissionRoleGroups() const;
      QList<QPair<int, QVariant>> ratingRoleGroups() const;
 +    QList<QPair<int, QVariant>> typeRoleGroups() const;
      QList<QPair<int, QVariant>> genericStringRoleGroups(const QByteArray &typeForRole) const;
  
      /**
@@@ -589,10 -542,6 +589,10 @@@ private
      bool m_sortHiddenLast;
  
      RoleType m_sortRole;
 +    RoleType m_groupRole;
 +    QByteArray m_sortExtraInfo;
 +    QByteArray m_groupExtraInfo;
 +
      int m_sortingProgressPercent; // Value of directorySortingProgress() signal
      QSet<QByteArray> m_roles;
  
@@@ -651,14 -600,4 +651,14 @@@ inline bool KFileItemModel::isChildItem
      }
  }
  
 +inline bool KFileItemModel::ItemGroupInfo::operator==(const ItemGroupInfo &other) const
 +{
 +    return comparable == other.comparable && text == other.text;
 +}
 +
 +inline bool KFileItemModel::ItemGroupInfo::operator!=(const ItemGroupInfo &other) const
 +{
 +    return comparable != other.comparable || text != other.text;
 +}
 +
  #endif
index 3828f0979cbb98576dbcc7a67015fe01c8dfd3b8,ac14ed795d06391309d7f8fb2308053b5c97ffb0..318936e8a69ed6fe98cfb1c88f2b9b210dc2edff
@@@ -16,6 -16,7 +16,7 @@@
  #include <KIO/ListJob>
  #include <KIO/PreviewJob>
  #include <KIconLoader>
+ #include <KIconUtils>
  #include <KJobWidgets>
  #include <KOverlayIconPlugin>
  #include <KPluginMetaData>
@@@ -368,7 -369,7 +369,7 @@@ void KFileItemModelRolesUpdater::slotIt
      timer.start();
  
      // Determine the sort role synchronously for as many items as possible.
 -    if (m_resolvableRoles.contains(m_model->sortRole())) {
 +    if (m_resolvableRoles.contains(m_model->sortRole()) || m_resolvableRoles.contains(m_model->groupRole())) {
          int insertedCount = 0;
          for (const KItemRange &range : itemRanges) {
              const int lastIndex = insertedCount + range.index + range.count - 1;
@@@ -557,15 -558,13 +558,13 @@@ void KFileItemModelRolesUpdater::slotGo
      const QStringList overlays = data["iconOverlays"].toStringList();
      // Strangely KFileItem::overlays() returns empty string-values, so
      // we need to check first whether an overlay must be drawn at all.
-     // It is more efficient to do it here, as KIconLoader::drawOverlays()
-     // assumes that an overlay will be drawn and has some additional
-     // setup time.
      if (!scaledPixmap.isNull()) {
          for (const QString &overlay : overlays) {
              if (!overlay.isEmpty()) {
                  // There is at least one overlay, draw all overlays above m_pixmap
                  // and cancel the check
-                 KIconLoader::global()->drawOverlays(overlays, scaledPixmap, KIconLoader::Desktop);
+                 const QSize size = scaledPixmap.size();
+                 scaledPixmap = KIconUtils::addOverlays(scaledPixmap, overlays).pixmap(size);
                  break;
              }
          }
@@@ -1218,13 -1217,13 +1217,13 @@@ void KFileItemModelRolesUpdater::applyS
      QHash<QByteArray, QVariant> data;
      const KFileItem item = m_model->fileItem(index);
  
 -    if (m_model->sortRole() == "type") {
 +    if (m_model->sortRole() == "type" || m_model->groupRole() == "type") {
          if (!item.isMimeTypeKnown()) {
              item.determineMimeType();
          }
  
          data.insert("type", item.mimeComment());
 -    } else if (m_model->sortRole() == "size" && item.isLocalFile() && item.isDir()) {
 +    } else if ((m_model->sortRole() == "size" || m_model->groupRole() == "size") && item.isLocalFile() && item.isDir()) {
          startDirectorySizeCounting(item, index);
          return;
      } else {
index 64b78d2bdc9fb37d3e20bbeeee18105968fef0f6,b3585e3fa7faa1072e8fd6b54e85297567b561c2..6046de4c687683e8cc4af4f6812d2d12c923584e
@@@ -110,8 -110,6 +110,8 @@@ bool ContextMenuSettingsPage::entryVisi
          return ContextMenuSettings::showAddToPlaces();
      } else if (id == "sort") {
          return ContextMenuSettings::showSortBy();
 +    } else if (id == "group") {
 +        return ContextMenuSettings::showGroupBy();
      } else if (id == "view_mode") {
          return ContextMenuSettings::showViewMode();
      } else if (id == "open_in_new_tab") {
@@@ -140,8 -138,6 +140,8 @@@ void ContextMenuSettingsPage::setEntryV
          ContextMenuSettings::setShowAddToPlaces(visible);
      } else if (id == "sort") {
          ContextMenuSettings::setShowSortBy(visible);
 +    } else if (id == "group") {
 +        ContextMenuSettings::setShowGroupBy(visible);
      } else if (id == "view_mode") {
          ContextMenuSettings::setShowViewMode(visible);
      } else if (id == "open_in_new_tab") {
@@@ -310,7 -306,6 +310,6 @@@ void ContextMenuSettingsPage::loadServi
      }
  
      m_sortModel->sort(Qt::DisplayRole);
-     m_searchLineEdit->setFocus(Qt::OtherFocusReason);
  }
  
  void ContextMenuSettingsPage::loadVersionControlSystems()
index 0fd4328057bd21b4ba566eb82d5f29b032d2af8b,782a03ae9132494e4f32f9099565573c069f7bfb..577dd23e2b703e940806820ceb9ffcdfdd0e0cca
@@@ -39,14 -39,6 +39,6 @@@ DolphinSettingsDialog::DolphinSettingsD
  
      setFaceType(List);
      setWindowTitle(i18nc("@title:window", "Configure"));
-     QDialogButtonBox *box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Apply | QDialogButtonBox::Cancel | QDialogButtonBox::RestoreDefaults);
-     box->button(QDialogButtonBox::Apply)->setEnabled(false);
-     box->button(QDialogButtonBox::Ok)->setDefault(true);
-     setButtonBox(box);
-     connect(box->button(QDialogButtonBox::Ok), &QAbstractButton::clicked, this, &DolphinSettingsDialog::applySettings);
-     connect(box->button(QDialogButtonBox::Apply), &QAbstractButton::clicked, this, &DolphinSettingsDialog::applySettings);
-     connect(box->button(QDialogButtonBox::RestoreDefaults), &QAbstractButton::clicked, this, &DolphinSettingsDialog::restoreDefaults);
  
      // Interface
      InterfaceSettingsPage *interfaceSettingsPage = new InterfaceSettingsPage(this);
@@@ -65,7 -57,6 +57,7 @@@
                                                                 actions,
                                                                 {QStringLiteral("add_to_places"),
                                                                  QStringLiteral("sort"),
 +                                                                QStringLiteral("group"),
                                                                  QStringLiteral("view_mode"),
                                                                  QStringLiteral("open_in_new_tab"),
                                                                  QStringLiteral("open_in_new_window"),
      }
  #endif
  
+     // Create the buttons last so they are also last in the keyboard Tab focus order.
+     QDialogButtonBox *box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Apply | QDialogButtonBox::Cancel | QDialogButtonBox::RestoreDefaults);
+     box->button(QDialogButtonBox::Apply)->setEnabled(false);
+     box->button(QDialogButtonBox::Ok)->setDefault(true);
+     setButtonBox(box);
+     connect(box->button(QDialogButtonBox::Ok), &QAbstractButton::clicked, this, &DolphinSettingsDialog::applySettings);
+     connect(box->button(QDialogButtonBox::Apply), &QAbstractButton::clicked, this, &DolphinSettingsDialog::applySettings);
+     connect(box->button(QDialogButtonBox::RestoreDefaults), &QAbstractButton::clicked, this, &DolphinSettingsDialog::restoreDefaults);
      const KConfigGroup dialogConfig(KSharedConfig::openStateConfig(), QStringLiteral("SettingsDialog"));
      KWindowConfig::restoreWindowSize(windowHandle(), dialogConfig);
  }
index 9dedb9661cd50cb569bce2fb427f044d1b4735be,11c0423bee6954670c74c7fd6d3d22f65cbd0bfe..abaed9c7c5b910bb7db5c2c2c15a30a163de37db
@@@ -231,7 -231,9 +231,9 @@@ DolphinView::DolphinView(const QUrl &ur
      m_versionControlObserver->setView(this);
      m_versionControlObserver->setModel(m_model);
      connect(m_versionControlObserver, &VersionControlObserver::infoMessage, this, &DolphinView::infoMessage);
-     connect(m_versionControlObserver, &VersionControlObserver::errorMessage, this, &DolphinView::errorMessage);
+     connect(m_versionControlObserver, &VersionControlObserver::errorMessage, this, [this](const QString &message) {
+         Q_EMIT errorMessage(message, KIO::ERR_UNKNOWN);
+     });
      connect(m_versionControlObserver, &VersionControlObserver::operationCompletedMessage, this, &DolphinView::operationCompletedMessage);
  
      m_twoClicksRenamingTimer = new QTimer(this);
@@@ -503,42 -505,6 +505,42 @@@ Qt::SortOrder DolphinView::sortOrder() 
      return m_model->sortOrder();
  }
  
 +void DolphinView::setGroupRole(const QByteArray &role)
 +{
 +    if (role != groupRole()) {
 +        ViewProperties props(viewPropertiesUrl());
 +        props.setGroupRole(role);
 +
 +        KItemModelBase *model = m_container->controller()->model();
 +        model->setGroupRole(role);
 +
 +        Q_EMIT groupRoleChanged(role);
 +    }
 +}
 +
 +QByteArray DolphinView::groupRole() const
 +{
 +    const KItemModelBase *model = m_container->controller()->model();
 +    return model->groupRole();
 +}
 +
 +void DolphinView::setGroupOrder(Qt::SortOrder order)
 +{
 +    if (groupOrder() != order) {
 +        ViewProperties props(viewPropertiesUrl());
 +        props.setGroupOrder(order);
 +
 +        m_model->setGroupOrder(order);
 +
 +        Q_EMIT groupOrderChanged(order);
 +    }
 +}
 +
 +Qt::SortOrder DolphinView::groupOrder() const
 +{
 +    return m_model->groupOrder();
 +}
 +
  void DolphinView::setSortFoldersFirst(bool foldersFirst)
  {
      if (sortFoldersFirst() != foldersFirst) {
@@@ -1154,7 -1120,7 +1156,7 @@@ void DolphinView::slotItemsActivated(co
  void DolphinView::slotItemMiddleClicked(int index)
  {
      const KFileItem &item = m_model->fileItem(index);
-     const QUrl &url = openItemAsFolderUrl(item);
+     const QUrl &url = openItemAsFolderUrl(item, GeneralSettings::browseThroughArchives());
      const auto modifiers = QGuiApplication::keyboardModifiers();
      if (!url.isEmpty()) {
          // keep in sync with KUrlNavigator::slotNavigatorButtonClicked
@@@ -1489,7 -1455,7 +1491,7 @@@ void DolphinView::onDirectoryLoadingCom
  void DolphinView::slotJobResult(KJob *job)
  {
      if (job->error() && job->error() != KIO::ERR_USER_CANCELED) {
-         Q_EMIT errorMessage(job->errorString());
+         Q_EMIT errorMessage(job->errorString(), job->error());
      }
      if (!m_selectJobCreatedItems) {
          m_selectedUrls.clear();
@@@ -1862,7 -1828,7 +1864,7 @@@ void DolphinView::slotTrashFileFinished
          selectNextItem(); // Fixes BUG: 419914 via selecting next item
          Q_EMIT operationCompletedMessage(i18nc("@info:status", "Trash operation completed."));
      } else if (job->error() != KIO::ERR_USER_CANCELED) {
-         Q_EMIT errorMessage(job->errorString());
+         Q_EMIT errorMessage(job->errorString(), job->error());
      }
  }
  
@@@ -1872,7 -1838,7 +1874,7 @@@ void DolphinView::slotDeleteFileFinishe
          selectNextItem(); // Fixes BUG: 419914 via selecting next item
          Q_EMIT operationCompletedMessage(i18nc("@info:status", "Delete operation completed."));
      } else if (job->error() != KIO::ERR_USER_CANCELED) {
-         Q_EMIT errorMessage(job->errorString());
+         Q_EMIT errorMessage(job->errorString(), job->error());
      }
  }
  
@@@ -2084,9 -2050,9 +2086,9 @@@ void DolphinView::loadDirectory(const Q
      if (!url.isValid()) {
          const QString location(url.toDisplayString(QUrl::PreferLocalFile));
          if (location.isEmpty()) {
-             Q_EMIT errorMessage(i18nc("@info:status", "The location is empty."));
+             Q_EMIT errorMessage(i18nc("@info:status", "The location is empty."), KIO::ERR_UNKNOWN);
          } else {
-             Q_EMIT errorMessage(i18nc("@info:status", "The location '%1' is invalid.", location));
+             Q_EMIT errorMessage(i18nc("@info:status", "The location '%1' is invalid.", location), KIO::ERR_UNKNOWN);
          }
          return;
      }
@@@ -2150,18 -2116,6 +2152,18 @@@ void DolphinView::applyViewProperties(c
          Q_EMIT sortOrderChanged(sortOrder);
      }
  
 +    const QByteArray groupRole = props.groupRole();
 +    if (groupRole != m_model->groupRole()) {
 +        m_model->setGroupRole(groupRole);
 +        Q_EMIT groupRoleChanged(groupRole);
 +    }
 +
 +    const Qt::SortOrder groupOrder = props.groupOrder();
 +    if (groupOrder != m_model->groupOrder()) {
 +        m_model->setGroupOrder(groupOrder);
 +        Q_EMIT groupOrderChanged(groupOrder);
 +    }
 +
      const bool sortFoldersFirst = props.sortFoldersFirst();
      if (sortFoldersFirst != m_model->sortDirectoriesFirst()) {
          m_model->setSortDirectoriesFirst(sortFoldersFirst);
diff --combined src/views/dolphinview.h
index f3c0189bf9ee19adba94956dc354103e9ad8be4b,c985f4eb9616998c612504ac891e18e6560609a1..2c772ad9034bcc32347b1e2df88243f53df1cabd
@@@ -51,8 -51,6 +51,8 @@@ class QRegularExpression
   * - show hidden files
   * - show previews
   * - enable grouping
 + * - grouping order
 + * - grouping type
   */
  class DOLPHIN_EXPORT DolphinView : public QWidget
  {
@@@ -221,20 -219,6 +221,20 @@@ public
      void setSortOrder(Qt::SortOrder order);
      Qt::SortOrder sortOrder() const;
  
 +    /**
 +     * Updates the view properties of the current URL to the
 +     * grouping given by \a role.
 +     */
 +    void setGroupRole(const QByteArray &role);
 +    QByteArray groupRole() const;
 +
 +    /**
 +     * Updates the view properties of the current URL to the
 +     * sort order given by \a order.
 +     */
 +    void setGroupOrder(Qt::SortOrder order);
 +    Qt::SortOrder groupOrder() const;
 +
      /** Sets a separate sorting with folders first (true) or a mixed sorting of files and folders (false). */
      void setSortFoldersFirst(bool foldersFirst);
      bool sortFoldersFirst() const;
@@@ -538,12 -522,6 +538,12 @@@ Q_SIGNALS
      /** Is emitted if the sort order (ascending or descending) has been changed. */
      void sortOrderChanged(Qt::SortOrder order);
  
 +    /** Is emitted if the grouping by name, size or date has been changed. */
 +    void groupRoleChanged(const QByteArray &role);
 +
 +    /** Is emitted if the group order (ascending or descending) has been changed. */
 +    void groupOrderChanged(Qt::SortOrder order);
 +
      /**
       * Is emitted if the sorting of files and folders (separate with folders
       * first or mixed) has been changed.
       * Is emitted if an error message with the content \a msg
       * should be shown.
       */
-     void errorMessage(const QString &msg);
+     void errorMessage(const QString &message, const int kioErrorCode);
  
      /**
       * Is emitted if an "operation completed" message with the content \a msg
index c5afe663d6fd4ab1a0fee13491a0e8fbcb4eb0a2,d65572f21ee1c203bd1e405c3dd5a7fe28bd3517..60c64306266d0f015cb70cb7d5f0c26b26cce889
@@@ -120,14 -120,16 +120,16 @@@ ViewProperties::ViewProperties(const QU
              setViewMode(DolphinView::DetailsView);
              setVisibleRoles({"text", "path", "deletiontime"});
          } else if (useRecentDocumentsView || useDownloadsView) {
-             setSortRole(QByteArrayLiteral("modificationtime"));
              setSortOrder(Qt::DescendingOrder);
              setSortFoldersFirst(false);
              setGroupedSorting(true);
  
              if (useRecentDocumentsView) {
+                 setSortRole(QByteArrayLiteral("accesstime"));
                  setViewMode(DolphinView::DetailsView);
-                 setVisibleRoles({"text", "path", "modificationtime"});
+                 setVisibleRoles({"text", "path", "accesstime"});
+             } else {
+                 setSortRole(QByteArrayLiteral("modificationtime"));
              }
          } else {
              // The global view-properties act as default for directories without
@@@ -253,32 -255,6 +255,32 @@@ Qt::SortOrder ViewProperties::sortOrder
      return static_cast<Qt::SortOrder>(m_node->sortOrder());
  }
  
 +void ViewProperties::setGroupRole(const QByteArray &role)
 +{
 +    if (m_node->groupRole() != role) {
 +        m_node->setGroupRole(role);
 +        update();
 +    }
 +}
 +
 +QByteArray ViewProperties::groupRole() const
 +{
 +    return m_node->groupRole().toLatin1();
 +}
 +
 +void ViewProperties::setGroupOrder(Qt::SortOrder groupOrder)
 +{
 +    if (m_node->groupOrder() != groupOrder) {
 +        m_node->setGroupOrder(groupOrder);
 +        update();
 +    }
 +}
 +
 +Qt::SortOrder ViewProperties::groupOrder() const
 +{
 +    return static_cast<Qt::SortOrder>(m_node->groupOrder());
 +}
 +
  void ViewProperties::setSortFoldersFirst(bool foldersFirst)
  {
      if (m_node->sortFoldersFirst() != foldersFirst) {
@@@ -410,8 -386,6 +412,8 @@@ void ViewProperties::setDirProperties(c
      setGroupedSorting(props.groupedSorting());
      setSortRole(props.sortRole());
      setSortOrder(props.sortOrder());
 +    setGroupRole(props.groupRole());
 +    setGroupOrder(props.groupOrder());
      setSortFoldersFirst(props.sortFoldersFirst());
      setSortHiddenLast(props.sortHiddenLast());
      setVisibleRoles(props.visibleRoles());