diff --git a/CMakeLists.txt b/CMakeLists.txt index b1f001c..0fe1c94 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/application.cpp b/application.cpp index 0466021..2286406 100644 --- a/application.cpp +++ b/application.cpp @@ -32,6 +32,7 @@ #include #include #include +#include // KIO #include @@ -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) diff --git a/application.h b/application.h index b66e587..dcd219e 100644 --- a/application.h +++ b/application.h @@ -23,6 +23,7 @@ #include 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 diff --git a/model/foldermodel.cpp b/model/foldermodel.cpp index a9c4a79..62ef042 100644 --- a/model/foldermodel.cpp +++ b/model/foldermodel.cpp @@ -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) diff --git a/model/foldermodel.h b/model/foldermodel.h index 2cf980a..400ceba 100644 --- a/model/foldermodel.h +++ b/model/foldermodel.h @@ -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(); diff --git a/qml.qrc b/qml.qrc index ec22118..9a642af 100644 --- a/qml.qrc +++ b/qml.qrc @@ -2,6 +2,7 @@ qml/main.qml qml/FolderPage.qml + qml/FolderTabBar.qml qml/FolderContextMenu.qml qml/ArchiveProgressDialog.qml qml/SideBar.qml diff --git a/qml/FolderContextMenu.qml b/qml/FolderContextMenu.qml index e7da85d..15c591d 100644 --- a/qml/FolderContextMenu.qml +++ b/qml/FolderContextMenu.qml @@ -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 diff --git a/qml/FolderPage.qml b/qml/FolderPage.qml index ff0667e..81b9f5e 100644 --- a/qml/FolderPage.qml +++ b/qml/FolderPage.qml @@ -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() + focusView() } - // 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() - } - - 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,15 +85,18 @@ 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: { - _viewLoader.item.currentIndex = dirModel.currentIndex + if (_viewLoader.item) + _viewLoader.item.currentIndex = dirModel.currentIndex } } @@ -163,17 +109,18 @@ Item { // Scroll to item. function onScrollToItem(index) { - _viewLoader.item.currentIndex = index + if (_viewLoader.item) + _viewLoader.item.currentIndex = index } } 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,7 +289,12 @@ Item { function openUrl(url) { dirModel.url = url - _viewLoader.item.forceActiveFocus() + focusView() + } + + function focusView() { + if (visible && _viewLoader.item) + _viewLoader.item.forceActiveFocus() } function goBack() { diff --git a/qml/FolderTabBar.qml b/qml/FolderTabBar.qml new file mode 100644 index 0000000..87212a3 --- /dev/null +++ b/qml/FolderTabBar.qml @@ -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" + } + } +} diff --git a/qml/OptionsMenu.qml b/qml/OptionsMenu.qml index a3334d9..a327d26 100644 --- a/qml/OptionsMenu.qml +++ b/qml/OptionsMenu.qml @@ -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 diff --git a/qml/PathBar.qml b/qml/PathBar.qml index 7915dbc..ed63db4 100644 --- a/qml/PathBar.qml +++ b/qml/PathBar.qml @@ -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 diff --git a/qml/SideBar.qml b/qml/SideBar.qml index 30264b6..e6aeda2 100644 --- a/qml/SideBar.qml +++ b/qml/SideBar.qml @@ -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 } diff --git a/qml/main.qml b/qml/main.qml index 723ff3b..f6b993f 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -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) } } } diff --git a/tests/tst_lifecycle.cpp b/tests/tst_lifecycle.cpp new file mode 100644 index 0000000..2f4a79b --- /dev/null +++ b/tests/tst_lifecycle.cpp @@ -0,0 +1,49 @@ +#include "application.h" +#include "qmltypes.h" + +#include +#include +#include + +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 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; +} diff --git a/tests/tst_tabs.cpp b/tests/tst_tabs.cpp new file mode 100644 index 0000000..3b7b4f8 --- /dev/null +++ b/tests/tst_tabs.cpp @@ -0,0 +1,167 @@ +#include "qmltypes.h" +#include "model/foldermodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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("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(); + }; + auto modelFor = [](QObject *page) { + return qobject_cast(page->property("model").value()); + }; + + QCOMPARE(evaluate("tabs.length").toInt(), 1); + auto *bar = window->findChild("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 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(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 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("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("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"