feat(filemanager): add reorderable folder tabs and stable window menus

main
reionwong 3 weeks ago
parent 6da810db92
commit cc2b984d41

@ -153,3 +153,17 @@ add_dependencies(cutefish-filemanager translations)
install(TARGETS cutefish-filemanager RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES cutefish-filemanager.desktop DESTINATION "/usr/share/applications")
install(FILES ${QM_FILES} DESTINATION /usr/share/cutefish-filemanager/translations)
option(FILEMANAGER_BUILD_TESTS "Build file manager integration tests" OFF)
if(FILEMANAGER_BUILD_TESTS)
enable_testing()
find_package(Qt6 REQUIRED COMPONENTS Test)
add_executable(tst_tabs tests/tst_tabs.cpp)
target_link_libraries(tst_tabs PRIVATE cutefish-filemanager-core Qt6::Test)
add_test(NAME tabs COMMAND tst_tabs)
set_tests_properties(tabs PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software")
add_executable(tst_lifecycle tests/tst_lifecycle.cpp application.cpp dbusinterface.cpp ${DBUS_SOURCES})
target_link_libraries(tst_lifecycle PRIVATE cutefish-filemanager-core Qt6::Test)
add_test(NAME lifecycle COMMAND tst_lifecycle)
set_tests_properties(lifecycle PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software")
endif()

@ -32,6 +32,7 @@
#include <QFileInfo>
#include <QIcon>
#include <QDir>
#include <QTimer>
// KIO
#include <KIO/CopyJob>
@ -49,6 +50,7 @@ Application::Application(int& argc, char** argv)
: QApplication(argc, argv)
, m_instance(false)
{
setQuitOnLastWindowClosed(true);
if (QDBusConnection::sessionBus().registerService("com.cutefish.FileManager")) {
setOrganizationName("cutefishos");
setWindowIcon(QIcon::fromTheme("file-manager"));
@ -114,6 +116,7 @@ void Application::emptyTrash()
{
Window *w = new Window;
w->load(QUrl("qrc:/qml/Dialogs/EmptyTrashDialog.qml"));
trackWindow(w);
}
void Application::openWindow(const QString &path)
@ -121,6 +124,21 @@ void Application::openWindow(const QString &path)
Window *w = new Window;
w->rootContext()->setContextProperty("arg", path);
w->load(QUrl("qrc:/qml/main.qml"));
trackWindow(w);
}
void Application::trackWindow(Window *window)
{
++m_windowCount;
const int exitCode = window->quickWindow() ? EXIT_SUCCESS : EXIT_FAILURE;
connect(window, &QObject::destroyed, this, [this, exitCode] {
--m_windowCount;
// Hidden helper windows must not keep the browser's D-Bus service alive.
QTimer::singleShot(0, this, [this, exitCode] {
if (m_windowCount == 0)
QCoreApplication::exit(exitCode);
});
});
}
QStringList Application::formatUriList(const QStringList &list)

@ -23,6 +23,7 @@
#include <QApplication>
namespace KIO { class Job; }
class Window;
class Application : public QApplication
{
@ -40,6 +41,7 @@ public:
private:
void openWindow(const QString &path);
void trackWindow(Window *window);
QStringList formatUriList(const QStringList &list);
/** Starts the trash job for @p paths, or returns nullptr if there is none. */
@ -50,6 +52,7 @@ private:
private:
bool m_instance;
int m_windowCount = 0;
};
#endif // APPLICATION_H

@ -1604,6 +1604,7 @@ void FolderModel::openContextMenu(QQuickItem *visualParent, Qt::KeyboardModifier
menu->addAction(m_actionCollection.action("open"));
menu->addAction(m_actionCollection.action("openInNewWindow"));
menu->addAction(m_actionCollection.action("openInNewTab"));
menu->addAction(m_actionCollection.action("openWith"));
menu->addAction(m_actionCollection.action("compress"));
@ -1711,6 +1712,16 @@ void FolderModel::openInNewWindow(const QString &url)
}
}
void FolderModel::openInNewTab()
{
const QModelIndexList indexes = m_selectionModel->selectedIndexes();
for (const QModelIndex &index : indexes) {
const KFileItem item = itemForIndex(index);
if (item.isDir())
emit openTabRequested(item.url().toString());
}
}
void FolderModel::updateSelectedItemsSize()
{
}
@ -2150,6 +2161,9 @@ void FolderModel::createActions()
QAction *openInNewWindow = new QAction(tr("Open in new window"), this);
QObject::connect(openInNewWindow, &QAction::triggered, this, [=] { this->openInNewWindow(); });
QAction *openInNewTab = new QAction(tr("Open In New Tab"), this);
connect(openInNewTab, &QAction::triggered, this, &FolderModel::openInNewTab);
m_actionCollection.addAction(QStringLiteral("openInNewTab"), openInNewTab);
m_actionCollection.addAction(QStringLiteral("open"), open);
m_actionCollection.addAction(QStringLiteral("openWith"), openWith);
@ -2312,6 +2326,9 @@ void FolderModel::updateActions()
if (QAction *openInNewWindow = m_actionCollection.action("openInNewWindow")) {
openInNewWindow->setVisible(hasDir && !isTrash);
}
if (QAction *openInNewTab = m_actionCollection.action("openInNewTab")) {
openInNewTab->setVisible(hasDir && !isTrash && !m_isDesktop);
}
}
void FolderModel::addDragImage(QDrag *drag, int x, int y)

@ -231,6 +231,7 @@ public:
Q_INVOKABLE void openChangeWallpaperDialog();
Q_INVOKABLE void openDeleteDialog();
Q_INVOKABLE void openInNewWindow(const QString &url = QString());
Q_INVOKABLE void openInNewTab();
Q_INVOKABLE void compressSelected();
Q_INVOKABLE void extractSelectedArchive();
Q_INVOKABLE void cancelArchive();
@ -258,6 +259,7 @@ public:
signals:
void urlChanged();
void openTabRequested(const QString &url);
void listingCompleted() const;
void listingCanceled() const;
void resolvedUrlChanged();

@ -2,6 +2,7 @@
<qresource prefix="/">
<file>qml/main.qml</file>
<file>qml/FolderPage.qml</file>
<file>qml/FolderTabBar.qml</file>
<file>qml/FolderContextMenu.qml</file>
<file>qml/ArchiveProgressDialog.qml</file>
<file>qml/SideBar.qml</file>

@ -134,6 +134,14 @@ FishUI.DesktopMenu {
onTriggered: modelAction.trigger()
}
FishUI.MenuItem {
property var modelAction: control.folderModel ? control.folderModel.action("openInNewTab") : null
text: modelAction ? modelAction.text : qsTr("Open In New Tab")
visible: control.hasSelection && modelAction && modelAction.visible
enabled: modelAction ? modelAction.enabled : false
onTriggered: modelAction.trigger()
}
FishUI.MenuItem {
id: openWithItem
property var modelAction: control.folderModel ? control.folderModel.action("openWith") : null

@ -21,7 +21,6 @@ import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import Qt5Compat.GraphicalEffects
import Qt.labs.platform 1.0
import Cutefish.FileManager 1.0 as FM
import FishUI 1.0 as FishUI
@ -38,86 +37,30 @@ Item {
property Item currentView: _viewLoader.item
property int headerHeight: 0
property int bottomNavigationHeight: 0
property string initialUrl: ""
readonly property string tabTitle: {
var path = currentUrl.toString()
if (path.indexOf("trash:/") === 0)
return qsTr("Trash")
if (path.indexOf("file://") === 0)
path = path.substring(7)
path = path.replace(/\/+$/, "")
var name = path.substring(path.lastIndexOf("/") + 1)
try { return decodeURIComponent(name) || "/" } catch (error) { return name || "/" }
}
signal requestPathEditor()
signal openInNewTab(string path)
signal closeRequested()
onCurrentUrlChanged: {
if (!_viewLoader.item)
return
_viewLoader.item.reset()
_viewLoader.item.forceActiveFocus()
}
// Global Menu
MenuBar {
id: appMenu
Menu {
title: qsTr("File")
MenuItem {
text: qsTr("New Folder")
onTriggered: dirModel.newFolder()
}
MenuSeparator {}
MenuItem {
text: qsTr("Properties")
onTriggered: dirModel.openPropertiesDialog()
focusView()
}
MenuSeparator {}
MenuItem {
text: qsTr("Quit")
onTriggered: root.close()
}
}
Menu {
title: qsTr("Edit")
MenuItem {
text: qsTr("Select All")
onTriggered: dirModel.selectAll()
}
MenuSeparator {}
MenuItem {
text: qsTr("Cut")
onTriggered: dirModel.cut()
}
MenuItem {
text: qsTr("Copy")
onTriggered: dirModel.copy()
}
MenuItem {
text: qsTr("Paste")
onTriggered: dirModel.paste()
}
}
Menu {
title: qsTr("Help")
MenuItem {
text: qsTr("About")
onTriggered: _aboutDialog.show()
}
}
}
FishUI.AboutDialog {
id: _aboutDialog
name: qsTr("File Manager")
description: qsTr("A file manager designed for CutefishOS.")
iconSource: "image://icontheme/file-system-manager"
}
Rectangle {
id: _background
@ -142,14 +85,17 @@ Item {
// showHiddenFiles: settings.showHiddenFiles
Component.onCompleted: {
if (arg)
dirModel.url = arg
if (folderPage.initialUrl)
dirModel.url = folderPage.initialUrl
else
dirModel.url = dirModel.homePath()
}
onOpenTabRequested: function(path) { folderPage.openInNewTab(path) }
// For new folder rename.
onCurrentIndexChanged: {
if (_viewLoader.item)
_viewLoader.item.currentIndex = dirModel.currentIndex
}
}
@ -163,6 +109,7 @@ Item {
// Scroll to item.
function onScrollToItem(index) {
if (_viewLoader.item)
_viewLoader.item.currentIndex = index
}
}
@ -170,10 +117,10 @@ Item {
FM.ItemViewAdapter {
id: viewAdapter
adapterView: _viewLoader.item
adapterModel: _viewLoader.item.positioner ? _viewLoader.item.positioner : dirModel
adapterModel: _viewLoader.item && _viewLoader.item.positioner ? _viewLoader.item.positioner : dirModel
adapterIconSize: 40
adapterVisibleArea: Qt.rect(_viewLoader.item.contentX, _viewLoader.item.contentY,
_viewLoader.item.contentWidth, _viewLoader.item.contentHeight)
adapterVisibleArea: _viewLoader.item ? Qt.rect(_viewLoader.item.contentX, _viewLoader.item.contentY,
_viewLoader.item.contentWidth, _viewLoader.item.contentHeight) : Qt.rect(0, 0, 0, 0)
}
FolderContextMenu {
@ -198,11 +145,8 @@ Item {
case 1: return _gridViewComponent
}
onSourceComponentChanged: {
// Focus
_viewLoader.item.forceActiveFocus()
// ShortCut
onLoaded: {
folderPage.focusView()
shortCut.install(_viewLoader.item)
}
}
@ -336,7 +280,7 @@ Item {
dirModel.showHiddenFiles = !dirModel.showHiddenFiles
}
onClose: {
root.close()
folderPage.closeRequested()
}
onUndo: {
dirModel.undo()
@ -345,6 +289,11 @@ Item {
function openUrl(url) {
dirModel.url = url
focusView()
}
function focusView() {
if (visible && _viewLoader.item)
_viewLoader.item.forceActiveFocus()
}

@ -0,0 +1,258 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import Qt5Compat.GraphicalEffects
import FishUI 1.0 as FishUI
Rectangle {
id: control
property var tabs: []
property int currentIndex: 0
property int dragIndex: -1
property int dropIndex: -1
property real pointerX: 0
property string dragTitle: ""
signal selected(int index)
signal closeRequested(int index)
signal newRequested()
signal duplicateRequested(string path)
signal moveRequested(int from, int to)
color: FishUI.Theme.secondBackgroundColor
readonly property color separatorColor: Qt.rgba(FishUI.Theme.textColor.r,
FishUI.Theme.textColor.g, FishUI.Theme.textColor.b, 0.10)
readonly property color hoverColor: Qt.rgba(FishUI.Theme.textColor.r,
FishUI.Theme.textColor.g, FishUI.Theme.textColor.b, 0.05)
function updateDrop(x) {
pointerX = x
dropIndex = Math.max(0, Math.min(tabs.length - 1,
Math.floor((x - list.x + list.contentX) / list.tabWidth)))
}
Rectangle {
x: list.x
y: list.y
width: list.width
height: list.height
radius: height / 2
color: FishUI.Theme.alternateBackgroundColor
}
ListView {
id: list
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 40
anchors.topMargin: 4
anchors.bottomMargin: 4
orientation: ListView.Horizontal
layoutDirection: Qt.LeftToRight
clip: true
model: control.tabs
currentIndex: control.currentIndex
readonly property real tabWidth: Math.max(140, width / Math.max(1, count))
boundsBehavior: Flickable.StopAtBounds
onCurrentIndexChanged: {
if (control.dragIndex < 0)
positionViewAtIndex(currentIndex, ListView.Contain)
}
onCountChanged: Qt.callLater(function() { list.positionViewAtIndex(list.currentIndex, ListView.Contain) })
delegate: Item {
id: tab
objectName: "folderTab" + index
width: list.tabWidth
height: list.height
property bool selected: index === control.currentIndex
opacity: control.dragIndex === index ? 0.25 : 1
Rectangle {
anchors.fill: parent
anchors.margins: 2
radius: height / 2
color: tab.selected ? (FishUI.Theme.darkMode ? Qt.lighter(FishUI.Theme.secondBackgroundColor, 2)
: FishUI.Theme.secondBackgroundColor)
: mouse.containsMouse ? control.hoverColor : "transparent"
layer.enabled: tab.selected
layer.effect: DropShadow {
transparentBorder: true
radius: 3
samples: 6
horizontalOffset: 0
verticalOffset: 1
color: Qt.rgba(0, 0, 0, FishUI.Theme.darkMode ? 0.18 : 0.12)
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
preventStealing: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
property real pressX: 0
property bool didDrag: false
onPressed: function(event) {
didDrag = false
pressX = mapToItem(control, event.x, event.y).x
if (event.button === Qt.LeftButton)
control.selected(index)
}
onPositionChanged: function(event) {
if (!(pressedButtons & Qt.LeftButton))
return
var x = mapToItem(control, event.x, event.y).x
if (!didDrag && Math.abs(x - pressX) > 8) {
didDrag = true
control.dragIndex = index
control.dragTitle = modelData.tabTitle
}
if (didDrag)
control.updateDrop(x)
}
onReleased: {
if (didDrag) {
var from = control.dragIndex
var to = control.dropIndex
control.dragIndex = -1
control.dropIndex = -1
control.moveRequested(from, to)
}
}
onCanceled: {
control.dragIndex = -1
control.dropIndex = -1
}
onClicked: function(event) {
if (didDrag)
return
if (event.button === Qt.MiddleButton)
control.closeRequested(index)
else if (event.button === Qt.RightButton)
menu.popup()
}
}
Label {
anchors.fill: parent
anchors.leftMargin: 28
anchors.rightMargin: 28
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: modelData.tabTitle
font.pixelSize: 12
font.weight: tab.selected ? Font.Medium : Font.Normal
elide: Text.ElideMiddle
color: FishUI.Theme.textColor
}
ToolButton {
id: closeButton
objectName: "tabCloseButton"
width: 20
height: 20
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
opacity: tab.selected || mouse.containsMouse || hovered ? 1 : 0
Accessible.name: qsTr("Close Tab")
onClicked: control.closeRequested(index)
contentItem: Item {
Rectangle {
anchors.centerIn: parent
width: 10
height: 1.2
rotation: 45
color: FishUI.Theme.textColor
}
Rectangle {
anchors.centerIn: parent
width: 10
height: 1.2
rotation: -45
color: FishUI.Theme.textColor
}
}
background: Rectangle {
radius: height / 2
color: closeButton.down ? control.separatorColor
: closeButton.hovered ? control.hoverColor : "transparent"
}
}
FishUI.DesktopMenu {
id: menu
FishUI.MenuItem {
text: qsTr("Open In New Tab")
onTriggered: control.duplicateRequested(modelData.currentUrl)
}
FishUI.MenuItem {
text: qsTr("Close Tab")
onTriggered: control.closeRequested(index)
}
}
}
}
Rectangle {
visible: control.dragIndex >= 0
x: Math.max(list.x, Math.min(list.x + list.width - width, control.pointerX - width / 2))
y: 4
width: Math.min(list.tabWidth - 2, list.width)
height: list.height - 2
radius: height / 2
color: FishUI.Theme.darkMode ? Qt.lighter(FishUI.Theme.secondBackgroundColor, 2)
: FishUI.Theme.secondBackgroundColor
opacity: 0.95
Label {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: control.dragTitle
elide: Text.ElideMiddle
color: FishUI.Theme.textColor
}
}
Rectangle {
visible: control.dragIndex >= 0 && control.dropIndex !== control.dragIndex
x: Math.max(list.x, Math.min(list.x + list.width - width,
list.x + (control.dropIndex + (control.dropIndex > control.dragIndex ? 1 : 0)) * list.tabWidth - list.contentX))
y: 6
width: 2
height: control.height - 12
radius: 1
color: FishUI.Theme.highlightColor
}
Timer {
interval: 30
repeat: true
running: control.dragIndex >= 0
onTriggered: {
var delta = control.pointerX < list.x + 24 ? -8
: control.pointerX > list.x + list.width - 24 ? 8 : 0
list.contentX = Math.max(0, Math.min(Math.max(0, list.contentWidth - list.width), list.contentX + delta))
control.updateDrop(control.pointerX)
}
}
ToolButton {
id: addButton
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
text: "+"
font.pixelSize: 20
Accessible.name: qsTr("New Tab")
onClicked: control.newRequested()
background: Rectangle {
radius: height / 2
color: addButton.hovered ? control.hoverColor : "transparent"
}
}
}

@ -25,6 +25,13 @@ import FishUI 1.0 as FishUI
FishUI.DesktopMenu {
id: control
FishUI.MenuItem {
text: qsTr("New Tab")
onTriggered: root.openTab(root._folderPage.currentUrl)
}
FishUI.MenuSeparator {}
FishUI.MenuItem {
text: qsTr("Icons")
reservesCheckColumn: true

@ -33,6 +33,8 @@ Item {
signal itemClicked(string path)
signal editorAccepted(string path)
signal openInNewTab(string path)
signal openInNewWindow(string path)
function nameForUrl(value) {
if (!value)
@ -141,6 +143,7 @@ Item {
height: ListView.view.height - ListView.view.topMargin - ListView.view.bottomMargin
width: _name.width + FishUI.Units.largeSpacing
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
z: -1
property bool selected: index === _pathView.count - 1
@ -159,11 +162,29 @@ Item {
dragged = true
}
onCanceled: dragged = true
onClicked: {
if (!dragged)
onClicked: function(mouse) {
if (dragged)
return
if (mouse.button === Qt.RightButton)
breadcrumbMenu.popup()
else if (mouse.button === Qt.MiddleButton)
control.openInNewTab(model.path)
else
control.itemClicked(model.path)
}
FishUI.DesktopMenu {
id: breadcrumbMenu
FishUI.MenuItem {
text: qsTr("Open In New Tab")
onTriggered: control.openInNewTab(model.path)
}
FishUI.MenuItem {
text: qsTr("Open in new window")
onTriggered: control.openInNewWindow(model.path)
}
}
Rectangle {
anchors.fill: parent
radius: FishUI.Theme.smallRadius

@ -44,6 +44,7 @@ Item {
signal clicked(string path)
signal openInNewWindow(string path)
signal openInNewTab(string path)
Fm {
id: _fm
@ -222,6 +223,12 @@ Item {
}
}
FishUI.MenuItem {
text: qsTr("Open In New Tab")
enabled: !model.isDevice || !model.setupNeeded
onTriggered: sideBar.openInNewTab(model.path ? model.path : model.url)
}
FishUI.MenuSeparator {
visible: index < placesModel.favoriteCount
}

@ -21,6 +21,7 @@ import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import QtQuick.Window 2.12
import Qt.labs.platform 1.0 as Platform
import FishUI 1.0 as FishUI
import "./Controls"
@ -42,6 +43,60 @@ FishUI.Window {
LayoutMirroring.childrenInherit: true
property QtObject settings: GlobalSettings { }
property var tabs: []
property int currentTab: 0
readonly property var _folderPage: tabs.length ? tabs[currentTab] : null
function openTab(path) {
var page = folderComponent.createObject(_content, { initialUrl: path || "" })
if (!page)
return
tabs = tabs.concat([page])
currentTab = tabs.length - 1
syncNavigation()
}
function closeTab(index) {
if (tabs.length === 1) {
root.close()
return
}
var page = tabs[index]
var remaining = tabs.slice()
remaining.splice(index, 1)
currentTab = Math.max(0, currentTab - (index <= currentTab ? 1 : 0))
tabs = remaining
page.destroy()
syncNavigation()
}
function syncNavigation() {
if (!_folderPage)
return
_pathBar.closeEditor()
_sideBar.updateSelection(_folderPage.currentUrl)
_pathBar.updateUrl(_folderPage.currentUrl)
_folderPage.focusView()
}
function moveTab(from, to) {
if (from === to || from < 0 || to < 0 || from >= tabs.length || to >= tabs.length)
return
var activePage = _folderPage
var reordered = tabs.slice()
reordered.splice(to, 0, reordered.splice(from, 1)[0])
tabs = reordered
currentTab = reordered.indexOf(activePage)
syncNavigation()
}
onCurrentTabChanged: syncNavigation()
Component.onCompleted: openTab(arg)
Shortcut { sequence: "Ctrl+T"; onActivated: root.openTab(_folderPage.currentUrl) }
Shortcut { sequence: "Ctrl+W"; onActivated: root.closeTab(root.currentTab) }
Shortcut { sequence: "Ctrl+Tab"; onActivated: root.currentTab = (root.currentTab + 1) % root.tabs.length }
Shortcut { sequence: "Ctrl+Shift+Tab"; onActivated: root.currentTab = (root.currentTab + root.tabs.length - 1) % root.tabs.length }
onClosing: {
if (root.visibility !== Window.Maximized &&
@ -51,13 +106,102 @@ FishUI.Window {
}
}
Platform.MenuBar {
id: appMenu
objectName: "applicationMenuBar"
window: root
Platform.Menu {
title: qsTr("File")
Platform.MenuItem {
text: qsTr("New Tab")
onTriggered: root.openTab(root._folderPage.currentUrl)
}
Platform.MenuItem {
text: qsTr("Open In New Tab")
enabled: root._folderPage && root._folderPage.model.action("openInNewTab").visible
onTriggered: root._folderPage.model.openInNewTab()
}
Platform.MenuItem {
text: qsTr("Close Tab")
onTriggered: root.closeTab(root.currentTab)
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: qsTr("New Folder")
onTriggered: root._folderPage.model.newFolder()
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: qsTr("Properties")
onTriggered: root._folderPage.model.openPropertiesDialog()
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: qsTr("Quit")
onTriggered: root.close()
}
}
Platform.Menu {
title: qsTr("Edit")
Platform.MenuItem {
text: qsTr("Select All")
onTriggered: root._folderPage.model.selectAll()
}
Platform.MenuSeparator {}
Platform.MenuItem {
text: qsTr("Cut")
onTriggered: root._folderPage.model.cut()
}
Platform.MenuItem {
text: qsTr("Copy")
onTriggered: root._folderPage.model.copy()
}
Platform.MenuItem {
text: qsTr("Paste")
onTriggered: root._folderPage.model.paste()
}
}
Platform.Menu {
title: qsTr("Help")
Platform.MenuItem {
text: qsTr("About")
onTriggered: _aboutDialog.show()
}
}
}
FishUI.AboutDialog {
id: _aboutDialog
name: qsTr("File Manager")
description: qsTr("A file manager designed for CutefishOS.")
iconSource: "image://icontheme/file-system-manager"
}
OptionsMenu {
id: optionsMenu
}
ArchiveProgressDialog {
id: archiveProgressDialog
archiveModel: _folderPage.model
archiveModel: _folderPage ? _folderPage.model : null
hostWindow: root
}
@ -81,7 +225,7 @@ FishUI.Window {
Layout.topMargin: root.windowButtonsTopMargin
Layout.preferredWidth: _headerRow.buttonSize
Layout.preferredHeight: _headerRow.buttonSize
enabled: _folderPage.canGoBack
enabled: _folderPage && _folderPage.canGoBack
source: FishUI.Theme.darkMode ? "qrc:/images/dark/go-previous.svg"
: "qrc:/images/light/go-previous.svg"
onClicked: _folderPage.goBack()
@ -92,7 +236,7 @@ FishUI.Window {
Layout.topMargin: root.windowButtonsTopMargin
Layout.preferredWidth: _headerRow.buttonSize
Layout.preferredHeight: _headerRow.buttonSize
enabled: _folderPage.canGoForward
enabled: _folderPage && _folderPage.canGoForward
source: FishUI.Theme.darkMode ? "qrc:/images/dark/go-next.svg"
: "qrc:/images/light/go-next.svg"
onClicked: _folderPage.goForward()
@ -137,17 +281,48 @@ FishUI.Window {
title: root.title
onClicked: _folderPage.openUrl(path)
onOpenInNewWindow: _folderPage.model.openInNewWindow(path)
onOpenInNewTab: root.openTab(path)
}
FolderPage {
id: _folderPage
Item {
id: _content
Layout.fillWidth: true
Layout.fillHeight: true
headerHeight: root.header.height
FolderTabBar {
id: tabBar
color: FishUI.Theme.secondBackgroundColor
objectName: "folderTabBar"
y: root.header.height
width: parent.width
z: 2
visible: root.tabs.length > 1
height: visible ? 38 : 0
tabs: root.tabs
currentIndex: root.currentTab
onSelected: function(index) { root.currentTab = index }
onCloseRequested: function(index) { root.closeTab(index) }
onNewRequested: root.openTab(_folderPage.currentUrl)
onDuplicateRequested: function(path) { root.openTab(path) }
onMoveRequested: function(from, to) { root.moveTab(from, to) }
}
}
}
Component {
id: folderComponent
FolderPage {
anchors.fill: parent
visible: root._folderPage === this
enabled: visible
headerHeight: root.header.height + tabBar.height
bottomNavigationHeight: root.header.height
onOpenInNewTab: function(path) { root.openTab(path) }
onCloseRequested: root.closeTab(root.currentTab)
onCurrentUrlChanged: {
_sideBar.updateSelection(currentUrl)
_pathBar.updateUrl(currentUrl)
if (visible)
root.syncNavigation()
}
onRequestPathEditor: {
_pathBar.openEditor()
@ -157,8 +332,8 @@ FishUI.Window {
Item {
id: _navigationBar
x: _folderPage.x
width: _folderPage.width
x: _content.x
width: _content.width
height: root.header.height
anchors.bottom: parent.bottom
z: 3
@ -187,6 +362,8 @@ FishUI.Window {
Layout.fillHeight: true
onItemClicked: _folderPage.openUrl(path)
onEditorAccepted: _folderPage.openUrl(path)
onOpenInNewTab: root.openTab(path)
onOpenInNewWindow: _folderPage.model.openInNewWindow(path)
}
}
}

@ -0,0 +1,49 @@
#include "application.h"
#include "qmltypes.h"
#include <QQuickWindow>
#include <QTemporaryDir>
#include <QTimer>
int main(int argc, char **argv)
{
QTemporaryDir config;
qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
CutefishFM::registerQmlTypes();
CutefishFM::initResources();
Application app(argc, argv);
QWindow helper;
helper.show();
bool closedLastWindow = false;
auto browserWindows = [] {
QList<QWindow *> windows;
for (auto *window : QGuiApplication::topLevelWindows()) {
if (window->isVisible() && window->property("tabs").isValid())
windows.append(window);
}
return windows;
};
QTimer::singleShot(0, &app, [&] {
app.openFiles({config.path(), config.path()});
const auto windows = browserWindows();
if (windows.size() != 2) {
app.exit(2);
return;
}
windows.first()->close();
QTimer::singleShot(100, &app, [&] {
const auto remaining = browserWindows();
if (remaining.size() != 1) {
app.exit(3);
return;
}
closedLastWindow = true;
remaining.first()->close();
});
});
QTimer::singleShot(5000, &app, [&] { app.exit(4); });
const int result = app.exec();
return closedLastWindow ? result : 5;
}

@ -0,0 +1,167 @@
#include "qmltypes.h"
#include "model/foldermodel.h"
#include <QApplication>
#include <QQuickItem>
#include <QQuickWindow>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQmlExpression>
#include <QTemporaryDir>
#include <QtTest>
#include <functional>
class TabsTest : public QObject
{
Q_OBJECT
private slots:
void navigationAndLifetime()
{
QTemporaryDir folders;
QVERIFY(folders.isValid());
QDir dir(folders.path());
QVERIFY(dir.mkdir("first"));
QVERIFY(dir.mkdir("second"));
QQmlApplicationEngine engine;
CutefishFM::registerImageProviders(&engine);
engine.rootContext()->setContextProperty("arg", folders.path());
engine.load(QUrl("qrc:/qml/main.qml"));
QVERIFY(!engine.rootObjects().isEmpty());
QObject *window = engine.rootObjects().first();
QObject *menuBar = window->findChild<QObject *>("applicationMenuBar");
QVERIFY(menuBar);
QSignalSpy menuWindowChanges(menuBar, SIGNAL(windowChanged()));
QVERIFY(menuWindowChanges.isValid());
auto evaluate = [&](const QString &code) {
QQmlExpression expression(engine.rootContext(), window, code);
const QVariant result = expression.evaluate();
if (expression.hasError())
qWarning() << expression.error();
return result;
};
auto activePage = [&]() {
return window->property("_folderPage").value<QObject *>();
};
auto modelFor = [](QObject *page) {
return qobject_cast<FolderModel *>(page->property("model").value<QObject *>());
};
QCOMPARE(evaluate("tabs.length").toInt(), 1);
auto *bar = window->findChild<QQuickItem *>("folderTabBar");
QVERIFY(bar);
QVERIFY(!bar->isVisible());
QCOMPARE(bar->height(), 0);
QObject *firstPage = activePage();
QVERIFY(firstPage);
FolderModel *firstModel = modelFor(firstPage);
QVERIFY(firstModel);
QTRY_COMPARE(firstModel->rowCount(), 2);
const QString initialUrl = firstModel->url();
firstModel->setUrl(QUrl::fromLocalFile(dir.filePath("first")).toString());
QTRY_VERIFY(firstModel->canGoBack());
const QString firstUrl = firstModel->url();
QVERIFY(QMetaObject::invokeMethod(window, "openTab", Q_ARG(QVariant, dir.filePath("second"))));
QCOMPARE(evaluate("tabs.length").toInt(), 2);
QCOMPARE(window->property("currentTab").toInt(), 1);
QVERIFY(bar->isVisible());
QCOMPARE(bar->height(), 38);
QObject *secondPage = activePage();
QVERIFY(secondPage != firstPage);
QVERIFY(!firstPage->property("visible").toBool());
QVERIFY(secondPage->property("visible").toBool());
QCOMPARE(secondPage->property("tabTitle").toString(), QStringLiteral("second"));
FolderModel *secondModel = modelFor(secondPage);
QVERIFY(secondModel);
const QString secondUrl = secondModel->url();
window->setProperty("currentTab", 0);
QCOMPARE(activePage(), firstPage);
QCOMPARE(firstModel->url(), firstUrl);
QVERIFY(firstModel->canGoBack());
firstModel->goBack();
QTRY_COMPARE(firstModel->url(), initialUrl);
QCOMPARE(secondModel->url(), secondUrl);
QTRY_COMPARE(firstModel->rowCount(), 2);
firstModel->selectAll();
firstModel->prepareContextMenu();
QVERIFY(firstModel->action("openInNewTab")->isVisible());
firstModel->openInNewTab();
QCOMPARE(evaluate("tabs.length").toInt(), 4);
QCOMPARE(firstModel->selectionCount(), 2);
QPointer<QObject> closing = activePage();
QVERIFY(QMetaObject::invokeMethod(window, "closeTab", Q_ARG(QVariant, 3)));
QTRY_VERIFY(closing.isNull());
QCOMPARE(evaluate("tabs.length").toInt(), 3);
evaluate("closeTab(2); closeTab(1)");
QCOMPARE(activePage(), firstPage);
QCOMPARE(evaluate("tabs.length").toInt(), 1);
QVERIFY(!bar->isVisible());
QCOMPARE(bar->height(), 0);
auto *quickWindow = qobject_cast<QQuickWindow *>(window);
QVERIFY(quickWindow);
QTest::keyClick(quickWindow, Qt::Key_T, Qt::ControlModifier);
QTRY_COMPARE(evaluate("tabs.length").toInt(), 2);
QTest::keyClick(quickWindow, Qt::Key_Tab, Qt::ControlModifier);
QTRY_COMPARE(window->property("currentTab").toInt(), 0);
QTest::keyClick(quickWindow, Qt::Key_W, Qt::ControlModifier);
QTRY_COMPARE(evaluate("tabs.length").toInt(), 1);
QVERIFY(QMetaObject::invokeMethod(window, "openTab", Q_ARG(QVariant, dir.filePath("second"))));
QObject *draggedPage = activePage();
std::function<QQuickItem *(QQuickItem *, const QString &)> findItem;
findItem = [&](QQuickItem *parent, const QString &name) -> QQuickItem * {
if (parent->objectName() == name)
return parent;
for (auto *child : parent->childItems()) {
if (auto *found = findItem(child, name))
return found;
}
return nullptr;
};
QTRY_VERIFY(findItem(bar, "folderTab1"));
auto *draggedTab = findItem(bar, "folderTab1");
auto *targetTab = findItem(bar, "folderTab0");
QVERIFY(targetTab);
auto *closeButton = draggedTab->findChild<QQuickItem *>("tabCloseButton");
QVERIFY(closeButton);
QVERIFY(closeButton->x() > draggedTab->width() / 2);
const QPoint from = draggedTab->mapToScene(QPointF(draggedTab->width() / 2, draggedTab->height() / 2)).toPoint();
const QPoint to = targetTab->mapToScene(QPointF(targetTab->width() / 2, targetTab->height() / 2)).toPoint();
QTest::mousePress(quickWindow, Qt::LeftButton, Qt::NoModifier, from);
QTest::mouseMove(quickWindow, to, 30);
QTRY_COMPARE(bar->property("dragIndex").toInt(), 1);
QTest::mouseRelease(quickWindow, Qt::LeftButton, Qt::NoModifier, to);
QTRY_COMPARE(window->property("currentTab").toInt(), 0);
QCOMPARE(activePage(), draggedPage);
QCOMPARE(evaluate("tabs.length").toInt(), 2);
QCOMPARE(menuWindowChanges.count(), 0);
QCOMPARE(window->findChildren<QObject *>("applicationMenuBar").count(), 1);
if (qEnvironmentVariableIsSet("FILEMANAGER_TEST_SCREENSHOT")) {
// The software renderer cannot render FishUI's rounded-window shader mask.
window->setProperty("windowRadius", 0);
QTest::qWait(150);
QVERIFY(quickWindow->grabWindow().save(qEnvironmentVariable("FILEMANAGER_TEST_SCREENSHOT")));
}
}
};
int main(int argc, char **argv)
{
QTemporaryDir config;
qputenv("XDG_CONFIG_HOME", config.path().toUtf8());
QApplication app(argc, argv);
app.setOrganizationName("cutefish-tests");
app.setApplicationName("tabs");
CutefishFM::registerQmlTypes();
CutefishFM::initResources();
TabsTest test;
return QTest::qExec(&test, argc, argv);
}
#include "tst_tabs.moc"
Loading…
Cancel
Save