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(TARGETS cutefish-filemanager RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES cutefish-filemanager.desktop DESTINATION "/usr/share/applications") install(FILES cutefish-filemanager.desktop DESTINATION "/usr/share/applications")
install(FILES ${QM_FILES} DESTINATION /usr/share/cutefish-filemanager/translations) 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 <QFileInfo>
#include <QIcon> #include <QIcon>
#include <QDir> #include <QDir>
#include <QTimer>
// KIO // KIO
#include <KIO/CopyJob> #include <KIO/CopyJob>
@ -49,6 +50,7 @@ Application::Application(int& argc, char** argv)
: QApplication(argc, argv) : QApplication(argc, argv)
, m_instance(false) , m_instance(false)
{ {
setQuitOnLastWindowClosed(true);
if (QDBusConnection::sessionBus().registerService("com.cutefish.FileManager")) { if (QDBusConnection::sessionBus().registerService("com.cutefish.FileManager")) {
setOrganizationName("cutefishos"); setOrganizationName("cutefishos");
setWindowIcon(QIcon::fromTheme("file-manager")); setWindowIcon(QIcon::fromTheme("file-manager"));
@ -114,6 +116,7 @@ void Application::emptyTrash()
{ {
Window *w = new Window; Window *w = new Window;
w->load(QUrl("qrc:/qml/Dialogs/EmptyTrashDialog.qml")); w->load(QUrl("qrc:/qml/Dialogs/EmptyTrashDialog.qml"));
trackWindow(w);
} }
void Application::openWindow(const QString &path) void Application::openWindow(const QString &path)
@ -121,6 +124,21 @@ void Application::openWindow(const QString &path)
Window *w = new Window; Window *w = new Window;
w->rootContext()->setContextProperty("arg", path); w->rootContext()->setContextProperty("arg", path);
w->load(QUrl("qrc:/qml/main.qml")); 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) QStringList Application::formatUriList(const QStringList &list)

@ -23,6 +23,7 @@
#include <QApplication> #include <QApplication>
namespace KIO { class Job; } namespace KIO { class Job; }
class Window;
class Application : public QApplication class Application : public QApplication
{ {
@ -40,6 +41,7 @@ public:
private: private:
void openWindow(const QString &path); void openWindow(const QString &path);
void trackWindow(Window *window);
QStringList formatUriList(const QStringList &list); QStringList formatUriList(const QStringList &list);
/** Starts the trash job for @p paths, or returns nullptr if there is none. */ /** Starts the trash job for @p paths, or returns nullptr if there is none. */
@ -50,6 +52,7 @@ private:
private: private:
bool m_instance; bool m_instance;
int m_windowCount = 0;
}; };
#endif // APPLICATION_H #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("open"));
menu->addAction(m_actionCollection.action("openInNewWindow")); menu->addAction(m_actionCollection.action("openInNewWindow"));
menu->addAction(m_actionCollection.action("openInNewTab"));
menu->addAction(m_actionCollection.action("openWith")); menu->addAction(m_actionCollection.action("openWith"));
menu->addAction(m_actionCollection.action("compress")); 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() void FolderModel::updateSelectedItemsSize()
{ {
} }
@ -2150,6 +2161,9 @@ void FolderModel::createActions()
QAction *openInNewWindow = new QAction(tr("Open in new window"), this); QAction *openInNewWindow = new QAction(tr("Open in new window"), this);
QObject::connect(openInNewWindow, &QAction::triggered, this, [=] { this->openInNewWindow(); }); 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("open"), open);
m_actionCollection.addAction(QStringLiteral("openWith"), openWith); m_actionCollection.addAction(QStringLiteral("openWith"), openWith);
@ -2312,6 +2326,9 @@ void FolderModel::updateActions()
if (QAction *openInNewWindow = m_actionCollection.action("openInNewWindow")) { if (QAction *openInNewWindow = m_actionCollection.action("openInNewWindow")) {
openInNewWindow->setVisible(hasDir && !isTrash); 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) void FolderModel::addDragImage(QDrag *drag, int x, int y)

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

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

@ -134,6 +134,14 @@ FishUI.DesktopMenu {
onTriggered: modelAction.trigger() 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 { FishUI.MenuItem {
id: openWithItem id: openWithItem
property var modelAction: control.folderModel ? control.folderModel.action("openWith") : null 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.Controls 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import Qt5Compat.GraphicalEffects import Qt5Compat.GraphicalEffects
import Qt.labs.platform 1.0
import Cutefish.FileManager 1.0 as FM import Cutefish.FileManager 1.0 as FM
import FishUI 1.0 as FishUI import FishUI 1.0 as FishUI
@ -38,86 +37,30 @@ Item {
property Item currentView: _viewLoader.item property Item currentView: _viewLoader.item
property int headerHeight: 0 property int headerHeight: 0
property int bottomNavigationHeight: 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 requestPathEditor()
signal openInNewTab(string path)
signal closeRequested()
onCurrentUrlChanged: { onCurrentUrlChanged: {
if (!_viewLoader.item) if (!_viewLoader.item)
return return
_viewLoader.item.reset() _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 { Rectangle {
id: _background id: _background
@ -142,14 +85,17 @@ Item {
// showHiddenFiles: settings.showHiddenFiles // showHiddenFiles: settings.showHiddenFiles
Component.onCompleted: { Component.onCompleted: {
if (arg) if (folderPage.initialUrl)
dirModel.url = arg dirModel.url = folderPage.initialUrl
else else
dirModel.url = dirModel.homePath() dirModel.url = dirModel.homePath()
} }
onOpenTabRequested: function(path) { folderPage.openInNewTab(path) }
// For new folder rename. // For new folder rename.
onCurrentIndexChanged: { onCurrentIndexChanged: {
if (_viewLoader.item)
_viewLoader.item.currentIndex = dirModel.currentIndex _viewLoader.item.currentIndex = dirModel.currentIndex
} }
} }
@ -163,6 +109,7 @@ Item {
// Scroll to item. // Scroll to item.
function onScrollToItem(index) { function onScrollToItem(index) {
if (_viewLoader.item)
_viewLoader.item.currentIndex = index _viewLoader.item.currentIndex = index
} }
} }
@ -170,10 +117,10 @@ Item {
FM.ItemViewAdapter { FM.ItemViewAdapter {
id: viewAdapter id: viewAdapter
adapterView: _viewLoader.item adapterView: _viewLoader.item
adapterModel: _viewLoader.item.positioner ? _viewLoader.item.positioner : dirModel adapterModel: _viewLoader.item && _viewLoader.item.positioner ? _viewLoader.item.positioner : dirModel
adapterIconSize: 40 adapterIconSize: 40
adapterVisibleArea: Qt.rect(_viewLoader.item.contentX, _viewLoader.item.contentY, adapterVisibleArea: _viewLoader.item ? Qt.rect(_viewLoader.item.contentX, _viewLoader.item.contentY,
_viewLoader.item.contentWidth, _viewLoader.item.contentHeight) _viewLoader.item.contentWidth, _viewLoader.item.contentHeight) : Qt.rect(0, 0, 0, 0)
} }
FolderContextMenu { FolderContextMenu {
@ -198,11 +145,8 @@ Item {
case 1: return _gridViewComponent case 1: return _gridViewComponent
} }
onSourceComponentChanged: { onLoaded: {
// Focus folderPage.focusView()
_viewLoader.item.forceActiveFocus()
// ShortCut
shortCut.install(_viewLoader.item) shortCut.install(_viewLoader.item)
} }
} }
@ -336,7 +280,7 @@ Item {
dirModel.showHiddenFiles = !dirModel.showHiddenFiles dirModel.showHiddenFiles = !dirModel.showHiddenFiles
} }
onClose: { onClose: {
root.close() folderPage.closeRequested()
} }
onUndo: { onUndo: {
dirModel.undo() dirModel.undo()
@ -345,6 +289,11 @@ Item {
function openUrl(url) { function openUrl(url) {
dirModel.url = url dirModel.url = url
focusView()
}
function focusView() {
if (visible && _viewLoader.item)
_viewLoader.item.forceActiveFocus() _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 { FishUI.DesktopMenu {
id: control id: control
FishUI.MenuItem {
text: qsTr("New Tab")
onTriggered: root.openTab(root._folderPage.currentUrl)
}
FishUI.MenuSeparator {}
FishUI.MenuItem { FishUI.MenuItem {
text: qsTr("Icons") text: qsTr("Icons")
reservesCheckColumn: true reservesCheckColumn: true

@ -33,6 +33,8 @@ Item {
signal itemClicked(string path) signal itemClicked(string path)
signal editorAccepted(string path) signal editorAccepted(string path)
signal openInNewTab(string path)
signal openInNewWindow(string path)
function nameForUrl(value) { function nameForUrl(value) {
if (!value) if (!value)
@ -141,6 +143,7 @@ Item {
height: ListView.view.height - ListView.view.topMargin - ListView.view.bottomMargin height: ListView.view.height - ListView.view.topMargin - ListView.view.bottomMargin
width: _name.width + FishUI.Units.largeSpacing width: _name.width + FishUI.Units.largeSpacing
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
z: -1 z: -1
property bool selected: index === _pathView.count - 1 property bool selected: index === _pathView.count - 1
@ -159,11 +162,29 @@ Item {
dragged = true dragged = true
} }
onCanceled: dragged = true onCanceled: dragged = true
onClicked: { onClicked: function(mouse) {
if (!dragged) 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) 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 { Rectangle {
anchors.fill: parent anchors.fill: parent
radius: FishUI.Theme.smallRadius radius: FishUI.Theme.smallRadius

@ -44,6 +44,7 @@ Item {
signal clicked(string path) signal clicked(string path)
signal openInNewWindow(string path) signal openInNewWindow(string path)
signal openInNewTab(string path)
Fm { Fm {
id: _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 { FishUI.MenuSeparator {
visible: index < placesModel.favoriteCount visible: index < placesModel.favoriteCount
} }

@ -21,6 +21,7 @@ import QtQuick 2.12
import QtQuick.Controls 2.12 import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import QtQuick.Window 2.12 import QtQuick.Window 2.12
import Qt.labs.platform 1.0 as Platform
import FishUI 1.0 as FishUI import FishUI 1.0 as FishUI
import "./Controls" import "./Controls"
@ -42,6 +43,60 @@ FishUI.Window {
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
property QtObject settings: GlobalSettings { } 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: { onClosing: {
if (root.visibility !== Window.Maximized && 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 { OptionsMenu {
id: optionsMenu id: optionsMenu
} }
ArchiveProgressDialog { ArchiveProgressDialog {
id: archiveProgressDialog id: archiveProgressDialog
archiveModel: _folderPage.model archiveModel: _folderPage ? _folderPage.model : null
hostWindow: root hostWindow: root
} }
@ -81,7 +225,7 @@ FishUI.Window {
Layout.topMargin: root.windowButtonsTopMargin Layout.topMargin: root.windowButtonsTopMargin
Layout.preferredWidth: _headerRow.buttonSize Layout.preferredWidth: _headerRow.buttonSize
Layout.preferredHeight: _headerRow.buttonSize Layout.preferredHeight: _headerRow.buttonSize
enabled: _folderPage.canGoBack enabled: _folderPage && _folderPage.canGoBack
source: FishUI.Theme.darkMode ? "qrc:/images/dark/go-previous.svg" source: FishUI.Theme.darkMode ? "qrc:/images/dark/go-previous.svg"
: "qrc:/images/light/go-previous.svg" : "qrc:/images/light/go-previous.svg"
onClicked: _folderPage.goBack() onClicked: _folderPage.goBack()
@ -92,7 +236,7 @@ FishUI.Window {
Layout.topMargin: root.windowButtonsTopMargin Layout.topMargin: root.windowButtonsTopMargin
Layout.preferredWidth: _headerRow.buttonSize Layout.preferredWidth: _headerRow.buttonSize
Layout.preferredHeight: _headerRow.buttonSize Layout.preferredHeight: _headerRow.buttonSize
enabled: _folderPage.canGoForward enabled: _folderPage && _folderPage.canGoForward
source: FishUI.Theme.darkMode ? "qrc:/images/dark/go-next.svg" source: FishUI.Theme.darkMode ? "qrc:/images/dark/go-next.svg"
: "qrc:/images/light/go-next.svg" : "qrc:/images/light/go-next.svg"
onClicked: _folderPage.goForward() onClicked: _folderPage.goForward()
@ -137,17 +281,48 @@ FishUI.Window {
title: root.title title: root.title
onClicked: _folderPage.openUrl(path) onClicked: _folderPage.openUrl(path)
onOpenInNewWindow: _folderPage.model.openInNewWindow(path) onOpenInNewWindow: _folderPage.model.openInNewWindow(path)
onOpenInNewTab: root.openTab(path)
} }
FolderPage { Item {
id: _folderPage id: _content
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: 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 bottomNavigationHeight: root.header.height
onOpenInNewTab: function(path) { root.openTab(path) }
onCloseRequested: root.closeTab(root.currentTab)
onCurrentUrlChanged: { onCurrentUrlChanged: {
_sideBar.updateSelection(currentUrl) if (visible)
_pathBar.updateUrl(currentUrl) root.syncNavigation()
} }
onRequestPathEditor: { onRequestPathEditor: {
_pathBar.openEditor() _pathBar.openEditor()
@ -157,8 +332,8 @@ FishUI.Window {
Item { Item {
id: _navigationBar id: _navigationBar
x: _folderPage.x x: _content.x
width: _folderPage.width width: _content.width
height: root.header.height height: root.header.height
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
z: 3 z: 3
@ -187,6 +362,8 @@ FishUI.Window {
Layout.fillHeight: true Layout.fillHeight: true
onItemClicked: _folderPage.openUrl(path) onItemClicked: _folderPage.openUrl(path)
onEditorAccepted: _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