diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index ed910b431..a794b0f03 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -241,6 +241,7 @@ if (ENABLE_TESTS) test/testimageutils.cpp test/testlayertree.cpp test/testlinks.cpp + test/testlocalprojectsmanager.cpp test/testmaptools.cpp test/testmerginapi.cpp test/testmodels.cpp @@ -268,6 +269,7 @@ if (ENABLE_TESTS) test/testimageutils.h test/testlayertree.h test/testlinks.h + test/testlocalprojectsmanager.h test/testmaptools.h test/testmerginapi.h test/testmodels.h diff --git a/app/activeproject.cpp b/app/activeproject.cpp index 77060f0f6..4329b9bcc 100644 --- a/app/activeproject.cpp +++ b/app/activeproject.cpp @@ -59,6 +59,18 @@ ActiveProject::ActiveProject( AppSettings &appSettings } } ); + // listen to local project rename event to unload mProject before it is renamed on disk + QObject::connect( + &mLocalProjectsManager, + &LocalProjectsManager::aboutToRenameLocalProject, + this, [this]( const QString & projectId ) + { + if ( projectId == mLocalProject.id() ) + { + load( QLatin1String() ); + } + } ); + // listen to metadata changes of opened LocalProject (e.g. local version update or namespace update) QObject::connect( &mLocalProjectsManager, diff --git a/app/projectsmodel.cpp b/app/projectsmodel.cpp index 26a58519a..55aff6d2e 100644 --- a/app/projectsmodel.cpp +++ b/app/projectsmodel.cpp @@ -53,6 +53,8 @@ void ProjectsModel::initializeProjectsModel() QObject::connect( mLocalProjectsManager, &LocalProjectsManager::localProjectAdded, this, &ProjectsModel::onProjectAdded ); QObject::connect( mLocalProjectsManager, &LocalProjectsManager::aboutToRemoveLocalProject, this, &ProjectsModel::onAboutToRemoveProject ); QObject::connect( mLocalProjectsManager, &LocalProjectsManager::localProjectDataChanged, this, &ProjectsModel::onProjectDataChanged ); + QObject::connect( mLocalProjectsManager, &LocalProjectsManager::localProjectRenamed, this, &ProjectsModel::onProjectRenamed ); + QObject::connect( mLocalProjectsManager, &LocalProjectsManager::renameLocalProjectFinished, this, &ProjectsModel::renameLocalProjectFinished ); QObject::connect( mLocalProjectsManager, &LocalProjectsManager::dataDirReloaded, this, &ProjectsModel::loadLocalProjects ); emit modelInitialized(); @@ -380,6 +382,31 @@ void ProjectsModel::removeLocalProject( const QString &projectId ) mLocalProjectsManager->removeLocalProject( projectId ); } +QString ProjectsModel::canRenameProject( const QString &projectId, const QString &newName ) const +{ + return mLocalProjectsManager->validateRename( projectId, newName ); +} + +void ProjectsModel::renameLocalProject( const QString &projectId, const QString &newName ) +{ + mLocalProjectsManager->renameLocalProject( projectId, newName ); +} + +void ProjectsModel::onProjectRenamed( const QString &oldProjectId, const LocalProject &localProject ) +{ + int ix = projectIndexFromId( oldProjectId ); + + if ( ix < 0 ) + return; + + Project &project = mProjects[ix]; + + project.local = localProject; + + QModelIndex editIndex = index( ix ); + emit dataChanged( editIndex, editIndex ); +} + void ProjectsModel::migrateProject( const QString &projectId ) { int ix = projectIndexFromId( projectId ); diff --git a/app/projectsmodel.h b/app/projectsmodel.h index 900bec57e..9cc3ac27c 100644 --- a/app/projectsmodel.h +++ b/app/projectsmodel.h @@ -127,6 +127,12 @@ class ProjectsModel : public QAbstractListModel //! Forwards call to LocalProjectsManager to remove local project Q_INVOKABLE void removeLocalProject( const QString &projectId ); + //! Forwards call to LocalProjectsManager to check whether a rename is valid + Q_INVOKABLE QString canRenameProject( const QString &projectId, const QString &newName ) const; + + //! Forwards call to LocalProjectsManager to rename local project + Q_INVOKABLE void renameLocalProject( const QString &projectId, const QString &newName ); + //! Migrates local project to mergin Q_INVOKABLE void migrateProject( const QString &projectId ); @@ -170,6 +176,7 @@ class ProjectsModel : public QAbstractListModel void onProjectAdded( const LocalProject &project ); void onAboutToRemoveProject( const LocalProject &project ); void onProjectDataChanged( const LocalProject &project ); + void onProjectRenamed( const QString &oldProjectId, const LocalProject &project ); void onAuthChanged(); @@ -193,6 +200,8 @@ class ProjectsModel : public QAbstractListModel void activeProjectIdChanged( QString projectId ); + void renameLocalProjectFinished( bool success ); + private: int projectIndexFromId( const QString &projectId ) const; diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index 83ef85a09..607d0875c 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -184,6 +184,7 @@ set(MM_QML project/components/MMProjectStatusItem.qml project/components/MMProjectWizardDelegate.qml project/components/MMProjectDelegate.qml + project/components/MMRenameProjectDialog.qml settings/MMAboutPage.qml settings/MMChangelogPage.qml settings/MMLogPage.qml diff --git a/app/qml/project/MMProjectList.qml b/app/qml/project/MMProjectList.qml index 7636434b9..93759f280 100644 --- a/app/qml/project/MMProjectList.qml +++ b/app/qml/project/MMProjectList.qml @@ -139,7 +139,7 @@ Item { return ["changes", "remove"] } else if ( !model.ProjectIsMergin && model.ProjectIsLocal ) { - return ["upload", "remove"] + return ["upload", "remove", "rename"] } return ["download"] } @@ -176,6 +176,10 @@ Item { } onStopSyncRequested: controllerModel.stopProjectSync( projectId ) onShowChangesRequested: root.showLocalChangesRequested( projectId ) + onRenameRequested: () => { + renameDialogLoader.projectIdToRename = projectId + renameDialogLoader.active = true + } } } @@ -306,6 +310,58 @@ Item { } } + Loader { + id: renameDialogLoader + + property string projectIdToRename: "" + + active: false + asynchronous: true + + sourceComponent: MMProjectComponents.MMRenameProjectDialog { + id: renameDialog + + onRenameClicked: function( newName ) { + if ( !renameDialogLoader.projectIdToRename ) { + return + } + + controllerModel.renameLocalProject( renameDialogLoader.projectIdToRename, newName ) + } + + onTextEdited: function( text ) { + canRenameCheckTimer.pendingText = text + canRenameCheckTimer.restart() + } + + onClosed: renameDialogLoader.active = false + + Component.onCompleted: open() + + Connections { + target: controllerModel + + function onRenameLocalProjectFinished( success ) { + if ( success ) { + renameDialog.close() + } + } + } + + Timer { + id: canRenameCheckTimer + + property string pendingText: "" + + interval: 300 + + onTriggered: { + renameDialog.errorText = controllerModel.canRenameProject( renameDialogLoader.projectIdToRename, pendingText ) + } + } + } + } + MMDownloadProjectDialog { id: downloadProjectDialog diff --git a/app/qml/project/components/MMProjectDelegate.qml b/app/qml/project/components/MMProjectDelegate.qml index b858b0666..9e692462c 100644 --- a/app/qml/project/components/MMProjectDelegate.qml +++ b/app/qml/project/components/MMProjectDelegate.qml @@ -25,7 +25,7 @@ Control { property bool projectIsInSync: false property real projectSyncProgress: 0.0 - property var projectActionButtons: [] // possible values: upload, changes, sync, download, remove + property var projectActionButtons: [] // possible values: upload, changes, sync, download, remove, rename property bool projectIsOpened: false @@ -35,6 +35,7 @@ Control { signal removeRequested() signal stopSyncRequested() signal showChangesRequested() + signal renameRequested() height: implicitHeight @@ -307,6 +308,11 @@ Control { "name": qsTr("Upload"), "iconSource": __style.uploadIcon, "callback": () => root.migrateRequested() + }, + "rename": { + "name": qsTr("Rename project"), + "iconSource": __style.editIcon, + "callback": () => root.renameRequested() } } } diff --git a/app/qml/project/components/MMRenameProjectDialog.qml b/app/qml/project/components/MMRenameProjectDialog.qml new file mode 100644 index 000000000..66136170a --- /dev/null +++ b/app/qml/project/components/MMRenameProjectDialog.qml @@ -0,0 +1,61 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../../components" +import "../../inputs" + +MMDrawer { + id: root + + property alias errorText: newNameField.errorMsg + + signal renameClicked( string newName ) + signal textEdited( string text ) + + drawerHeader.title: qsTr( "Rename project" ) + drawerHeader.titleFont: __style.t2 + + onAboutToShow: () => { + newNameField.errorMsg = "" + newNameField.text = "" + } + + drawerContent: Column { + id: contentColumn + + width: parent.width + spacing: newNameField.errorMsg ? __style.margin12 : __style.spacing40 + + MMTextInput { + id: newNameField + + width: contentColumn.width + textFieldBackground.color: __style.lightGreenColor + + placeholderText: qsTr( "Enter the new name" ) + + onTextEdited: ( text ) => { + newNameField.errorMsg = "" + root.textEdited( text ) + } + } + + MMButton { + width: contentColumn.width + + text: qsTr( "Confirm" ) + + onClicked: { + root.renameClicked( newNameField.text ) + } + } + } +} \ No newline at end of file diff --git a/app/test/inputtests.cpp b/app/test/inputtests.cpp index 5fe25939e..6b359bb87 100644 --- a/app/test/inputtests.cpp +++ b/app/test/inputtests.cpp @@ -27,6 +27,7 @@ #include "testimageutils.h" #include "testmaptools.h" #include "testlayertree.h" +#include "testlocalprojectsmanager.h" #include "testactiveproject.h" #include "testprojectchecksumcache.h" #include "testmultieditmanager.h" @@ -172,6 +173,11 @@ int InputTests::runTest() const TestLayerTree layerTreeTest; nFailed = QTest::qExec( &layerTreeTest, mTestArgs ); } + else if ( mTestRequested == "--testLocalProjectsManager" ) + { + TestLocalProjectsManager localProjectsManagerTest; + nFailed = QTest::qExec( &localProjectsManagerTest, mTestArgs ); + } else if ( mTestRequested == "--testActiveProject" ) { TestActiveProject activeProjectTest( mApi ); diff --git a/app/test/testlocalprojectsmanager.cpp b/app/test/testlocalprojectsmanager.cpp new file mode 100644 index 000000000..e8f3fd88d --- /dev/null +++ b/app/test/testlocalprojectsmanager.cpp @@ -0,0 +1,238 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "testlocalprojectsmanager.h" +#include "localprojectsmanager.h" +#include "testutils.h" + +#include +#include +#include + +void TestLocalProjectsManager::init() +{ + mDataDir = QDir::tempPath() + QStringLiteral( "/testLocalProjectsManager" ); + + QDir dir( mDataDir ); + if ( dir.exists() ) + dir.removeRecursively(); + + QDir().mkpath( mDataDir ); + + TestUtils::createFakeLocalProject( mDataDir, QStringLiteral( "OriginalName" ) ); +} + +void TestLocalProjectsManager::cleanup() +{ + QDir( mDataDir ).removeRecursively(); +} + +void TestLocalProjectsManager::testRenameSuccess() +{ + LocalProjectsManager manager( mDataDir ); + QCOMPARE( manager.projects().size(), 1 ); + + QString projectId = manager.projects().first().id(); + QCOMPARE( projectId, QStringLiteral( "OriginalName" ) ); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + manager.renameLocalProject( projectId, QStringLiteral( "NewName" ) ); + + QCOMPARE( finishedSpy.count(), 1 ); + QCOMPARE( finishedSpy.at( 0 ).at( 0 ).toBool(), true ); + QCOMPARE( renamedSpy.count(), 1 ); + QCOMPARE( renamedSpy.at( 0 ).at( 0 ).toString(), projectId ); + + QVERIFY( !QDir( mDataDir + "/OriginalName" ).exists() ); + QVERIFY( QDir( mDataDir + "/NewName" ).exists() ); + QVERIFY( QFile::exists( mDataDir + "/NewName/NewName.qgz" ) ); + + LocalProject updated = manager.projectFromProjectId( QStringLiteral( "NewName" ) ); + QVERIFY( updated.isValid() ); + QCOMPARE( updated.projectName, QStringLiteral( "NewName" ) ); + QCOMPARE( updated.projectDir, mDataDir + "/NewName" ); + QCOMPARE( updated.qgisProjectFilePath, mDataDir + "/NewName/NewName.qgz" ); +} + +void TestLocalProjectsManager::testRenameEmptyName() +{ + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + manager.renameLocalProject( projectId, QString() ); + + QCOMPARE( finishedSpy.count(), 1 ); + QCOMPARE( finishedSpy.at( 0 ).at( 0 ).toBool(), false ); + QCOMPARE( renamedSpy.count(), 0 ); + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); + + manager.renameLocalProject( projectId, QStringLiteral( " " ) ); + + QCOMPARE( finishedSpy.count(), 2 ); + QCOMPARE( finishedSpy.at( 1 ).at( 0 ).toBool(), false ); + QCOMPARE( renamedSpy.count(), 0 ); + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); +} + +void TestLocalProjectsManager::testRenameInvalidCharacters() +{ + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + manager.renameLocalProject( projectId, QStringLiteral( "Bad/Name" ) ); + + QCOMPARE( finishedSpy.count(), 1 ); + QCOMPARE( finishedSpy.at( 0 ).at( 0 ).toBool(), false ); + QCOMPARE( renamedSpy.count(), 0 ); + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); +} + +void TestLocalProjectsManager::testRenameNameAlreadyTaken() +{ + TestUtils::createFakeLocalProject( mDataDir, QStringLiteral( "OtherProject" ) ); + + LocalProjectsManager manager( mDataDir ); + QCOMPARE( manager.projects().size(), 2 ); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + manager.renameLocalProject( QStringLiteral( "OriginalName" ), QStringLiteral( "OtherProject" ) ); + + QCOMPARE( finishedSpy.count(), 1 ); + QCOMPARE( finishedSpy.at( 0 ).at( 0 ).toBool(), false ); + QCOMPARE( renamedSpy.count(), 0 ); + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); + QVERIFY( QDir( mDataDir + "/OtherProject" ).exists() ); +} + +void TestLocalProjectsManager::testRenameSameName() +{ + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + manager.renameLocalProject( projectId, QStringLiteral( "OriginalName" ) ); + + // a no-op rename still reports success, but nothing on disk is touched + QCOMPARE( finishedSpy.count(), 1 ); + QCOMPARE( finishedSpy.at( 0 ).at( 0 ).toBool(), true ); + QCOMPARE( renamedSpy.count(), 1 ); + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); +} + +void TestLocalProjectsManager::testRenameUnknownProject() +{ + LocalProjectsManager manager( mDataDir ); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + manager.renameLocalProject( QStringLiteral( "does-not-exist" ), QStringLiteral( "NewName" ) ); + + QCOMPARE( finishedSpy.count(), 1 ); + QCOMPARE( finishedSpy.at( 0 ).at( 0 ).toBool(), false ); + QCOMPARE( renamedSpy.count(), 0 ); + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); + QVERIFY( !QDir( mDataDir + "/NewName" ).exists() ); +} + +void TestLocalProjectsManager::testRenameDirectoryCollision() +{ + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + // A folder that already exists on disk at the rename target path, but that the + // manager does not know about (created after it last scanned mDataDir). + QDir().mkpath( mDataDir + "/NewName" ); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + manager.renameLocalProject( projectId, QStringLiteral( "NewName" ) ); + + QCOMPARE( finishedSpy.count(), 1 ); + QCOMPARE( finishedSpy.at( 0 ).at( 0 ).toBool(), true ); + QCOMPARE( renamedSpy.count(), 1 ); + QVERIFY( QDir( mDataDir + "/NewName (1)" ).exists() ); + + LocalProject updated = manager.projectFromProjectId( QStringLiteral( "NewName (1)" ) ); + QVERIFY( updated.isValid() ); + QCOMPARE( updated.projectDir, mDataDir + "/NewName (1)" ); +} + +void TestLocalProjectsManager::testRenameTrimsWhitespace() +{ + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + manager.renameLocalProject( projectId, QStringLiteral( " NewName " ) ); + + QCOMPARE( finishedSpy.count(), 1 ); + QCOMPARE( finishedSpy.at( 0 ).at( 0 ).toBool(), true ); + QCOMPARE( renamedSpy.count(), 1 ); + QVERIFY( QDir( mDataDir + "/NewName" ).exists() ); + + LocalProject updated = manager.projectFromProjectId( QStringLiteral( "NewName" ) ); + QVERIFY( updated.isValid() ); + QCOMPARE( updated.projectName, QStringLiteral( "NewName" ) ); +} + +void TestLocalProjectsManager::testValidateRenameAccepts() +{ + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + QSignalSpy finishedSpy( &manager, &LocalProjectsManager::renameLocalProjectFinished ); + + QCOMPARE( manager.validateRename( projectId, QStringLiteral( "NewName" ) ), QString() ); + QCOMPARE( manager.validateRename( projectId, QStringLiteral( "OriginalName" ) ), QString() ); // unchanged name is fine too + + // validateRename() must be side-effect-free + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); + QVERIFY( QFile::exists( mDataDir + "/OriginalName/OriginalName.qgz" ) ); + QVERIFY( !QDir( mDataDir + "/NewName" ).exists() ); + + const LocalProject project = manager.projectFromProjectId( projectId ); + QVERIFY( project.isValid() ); + QCOMPARE( project.projectName, QStringLiteral( "OriginalName" ) ); + QCOMPARE( project.projectDir, mDataDir + "/OriginalName" ); + QCOMPARE( project.qgisProjectFilePath, mDataDir + "/OriginalName/OriginalName.qgz" ); + + QCOMPARE( renamedSpy.count(), 0 ); + QCOMPARE( finishedSpy.count(), 0 ); +} + +void TestLocalProjectsManager::testValidateRenameRejects() +{ + TestUtils::createFakeLocalProject( mDataDir, QStringLiteral( "OtherProject" ) ); + + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projectFromDirectory( mDataDir + "/OriginalName" ).id(); + + QVERIFY( !manager.validateRename( projectId, QString() ).isEmpty() ); + QVERIFY( !manager.validateRename( projectId, QStringLiteral( " " ) ).isEmpty() ); + QVERIFY( !manager.validateRename( projectId, QStringLiteral( "Bad/Name" ) ).isEmpty() ); + QVERIFY( !manager.validateRename( projectId, QStringLiteral( "OtherProject" ) ).isEmpty() ); + QVERIFY( !manager.validateRename( QStringLiteral( "does-not-exist" ), QStringLiteral( "NewName" ) ).isEmpty() ); +} diff --git a/app/test/testlocalprojectsmanager.h b/app/test/testlocalprojectsmanager.h new file mode 100644 index 000000000..d00198cde --- /dev/null +++ b/app/test/testlocalprojectsmanager.h @@ -0,0 +1,39 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef TESTLOCALPROJECTSMANAGER_H +#define TESTLOCALPROJECTSMANAGER_H + +#include + +class TestLocalProjectsManager : public QObject +{ + Q_OBJECT + + private slots: + void init(); // will be called before each testfunction is executed. + void cleanup(); // will be called after every testfunction. + + void testRenameSuccess(); + void testRenameEmptyName(); + void testRenameInvalidCharacters(); + void testRenameNameAlreadyTaken(); + void testRenameSameName(); + void testRenameUnknownProject(); + void testRenameDirectoryCollision(); + void testRenameTrimsWhitespace(); + + void testValidateRenameAccepts(); + void testValidateRenameRejects(); + + private: + QString mDataDir; +}; + +#endif // TESTLOCALPROJECTSMANAGER_H diff --git a/app/test/testmodels.cpp b/app/test/testmodels.cpp index 48682ace1..b669cb332 100644 --- a/app/test/testmodels.cpp +++ b/app/test/testmodels.cpp @@ -527,3 +527,97 @@ void TestModels::testProjectsProxyModel() QCOMPARE( proxy.data( proxy.index( 1, 0 ), ProjectsModel::Roles::ProjectId ).toString(), p1.id() ); QCOMPARE( proxy.data( proxy.index( 2, 0 ), ProjectsModel::Roles::ProjectId ).toString(), p0.id() ); } + +void TestModels::testProjectsModelOnProjectRenamed() +{ + // A purely local project - no namespace, so its id() is derived from its directory name, + // exactly like LocalProjectsManager::renameLocalProject() produces after renaming on disk. + Project p0; + p0.local.projectName = QStringLiteral( "OriginalName" ); + p0.local.projectDir = QDir::tempPath() + QStringLiteral( "/OriginalName" ); + + Project p1; + p1.local.projectNamespace = QStringLiteral( "namespace" ); + p1.local.projectName = QStringLiteral( "project_A" ); + p1.local.projectDir = QStringLiteral( "project_A_dir" ); + + ProjectsModel model; + model.setModelType( ProjectsModel::LocalProjectsModel ); + model.mProjects << p0 << p1; + + QCOMPARE( model.rowCount(), 2 ); + + QString oldId = p0.local.id(); + + LocalProject renamed = p0.local; + renamed.projectName = QStringLiteral( "NewName" ); + renamed.projectDir = QDir::tempPath() + QStringLiteral( "/NewName" ); + + QVERIFY( oldId != renamed.id() ); // renaming a local-only project does change its id + + QSignalSpy dataChangedSpy( &model, &QAbstractItemModel::dataChanged ); + + model.onProjectRenamed( oldId, renamed ); + + // this is an in-place update of an existing row, not an add/remove + QCOMPARE( model.rowCount(), 2 ); + QCOMPARE( dataChangedSpy.count(), 1 ); + + // the row can be found again via its NEW id - this is exactly the lookup that used to be + // broken, since the row was previously only searchable under its OLD (pre-rename) id + QModelIndex newIndex = model.projectModelIndexFromId( renamed.id() ); + QVERIFY( newIndex.isValid() ); + QCOMPARE( model.data( newIndex, ProjectsModel::Roles::ProjectName ).toString(), QStringLiteral( "NewName" ) ); + QCOMPARE( model.data( newIndex, ProjectsModel::Roles::ProjectId ).toString(), renamed.id() ); + + // the row is no longer reachable under its old id + QVERIFY( !model.projectModelIndexFromId( oldId ).isValid() ); + + // the other project in the model is untouched + QModelIndex otherIndex = model.projectModelIndexFromId( p1.id() ); + QCOMPARE( model.data( otherIndex, ProjectsModel::Roles::ProjectName ).toString(), QStringLiteral( "project_A" ) ); +} + +void TestModels::testProjectsModelOnProjectRenamedActiveProject() +{ + Project p0; + p0.local.projectName = QStringLiteral( "OriginalName" ); + p0.local.projectDir = QDir::tempPath() + QStringLiteral( "/OriginalName" ); + + Project p1; + p1.local.projectNamespace = QStringLiteral( "namespace" ); + p1.local.projectName = QStringLiteral( "project_A" ); + p1.local.projectDir = QStringLiteral( "project_A_dir" ); + + ProjectsModel model; + model.setModelType( ProjectsModel::LocalProjectsModel ); + model.mProjects << p0 << p1; + + QString oldId = p0.local.id(); + + LocalProject renamed = p0.local; + renamed.projectName = QStringLiteral( "NewName" ); + renamed.projectDir = QDir::tempPath() + QStringLiteral( "/NewName" ); + + // Renaming a project that is NOT the active one must leave activeProjectId untouched + model.setActiveProjectId( p1.id() ); + QSignalSpy inactiveRenameSpy( &model, &ProjectsModel::activeProjectIdChanged ); + + model.onProjectRenamed( oldId, renamed ); + + QCOMPARE( model.activeProjectId(), p1.id() ); + QCOMPARE( inactiveRenameSpy.count(), 0 ); + + // Renaming the ACTIVE project must NOT make ProjectsModel follow the id itself anymore + model.setActiveProjectId( renamed.id() ); // simulate that the just-renamed project is now active + QSignalSpy activeRenameSpy( &model, &ProjectsModel::activeProjectIdChanged ); + + LocalProject renamedAgain = renamed; + renamedAgain.projectName = QStringLiteral( "NewerName" ); + renamedAgain.projectDir = QDir::tempPath() + QStringLiteral( "/NewerName" ); + + model.onProjectRenamed( renamed.id(), renamedAgain ); + + QCOMPARE( model.activeProjectId(), renamed.id() ); + QCOMPARE( activeRenameSpy.count(), 0 ); +} diff --git a/app/test/testmodels.h b/app/test/testmodels.h index a6ea309cf..dd88434e9 100644 --- a/app/test/testmodels.h +++ b/app/test/testmodels.h @@ -28,6 +28,8 @@ class TestModels : public QObject void testValueRelationHotreload(); void testProjectsModel(); void testProjectsProxyModel(); + void testProjectsModelOnProjectRenamed(); + void testProjectsModelOnProjectRenamedActiveProject(); }; diff --git a/app/test/testutils.cpp b/app/test/testutils.cpp index be15db547..be15ae494 100644 --- a/app/test/testutils.cpp +++ b/app/test/testutils.cpp @@ -8,6 +8,8 @@ ***************************************************************************/ #include "QtDebug" +#include +#include #include #include #include @@ -251,6 +253,18 @@ bool TestUtils::generateProjectFolder( const QString &rootPath, const QJsonDocum return allGood; } +QString TestUtils::createFakeLocalProject( const QString &dataDir, const QString &name ) +{ + const QString projectDir = dataDir + "/" + name; + QDir().mkpath( projectDir ); + + QFile qgzFile( projectDir + "/" + name + ".qgz" ); + qgzFile.open( QIODevice::WriteOnly ); + qgzFile.close(); + + return projectDir; +} + QgsProject *TestUtils::loadPlanesTestProject() { QString projectDir = TestUtils::testDataDir() + "/planes"; diff --git a/app/test/testutils.h b/app/test/testutils.h index 3de1e286b..15bfebc88 100644 --- a/app/test/testutils.h +++ b/app/test/testutils.h @@ -73,6 +73,10 @@ namespace TestUtils */ bool generateProjectFolder( const QString &rootPath, const QJsonDocument &structure ); + //! Creates a fake local project directory dataDir/name containing an empty name.qgz file, + //! so LocalProjectsManager can discover it. Returns the project directory path. + QString createFakeLocalProject( const QString &dataDir, const QString &name ); + bool testExifPositionMetadataExists( const QString &imageSource ); //! Creates an in-memory layer with a single field of the given type and registers it in QgsProject::instance() diff --git a/core/localprojectsmanager.cpp b/core/localprojectsmanager.cpp index f3d134fc8..d9a9b53d3 100644 --- a/core/localprojectsmanager.cpp +++ b/core/localprojectsmanager.cpp @@ -15,6 +15,8 @@ #include #include +#include +#include LocalProjectsManager::LocalProjectsManager( const QString &dataDir ) : mDataDir( dataDir ) @@ -25,6 +27,7 @@ LocalProjectsManager::LocalProjectsManager( const QString &dataDir ) void LocalProjectsManager::reloadDataDir() { mProjects.clear(); + QStringList entryList = QDir( mDataDir ).entryList( QDir::NoDotAndDotDot | QDir::Dirs ); for ( const QString &folderName : entryList ) { @@ -249,3 +252,109 @@ void LocalProjectsManager::addProject( const QString &projectDir, const QString mProjects << project; emit localProjectAdded( project ); } + +QString LocalProjectsManager::validateRename( const QString &projectId, const QString &newName ) const +{ + int projectIndex = -1; + return validateRename( projectId, newName, projectIndex ); +} + +QString LocalProjectsManager::validateRename( const QString &projectId, const QString &newName, int &projectIndexOut ) const +{ + const QString trimmedName = newName.trimmed(); + + if ( trimmedName.isEmpty() ) + { + return tr( "The project name cannot be empty" ); + } + + if ( !CoreUtils::isValidName( trimmedName ) ) + { + return tr( "The project name contains invalid characters" ); + } + + int projectIndex = -1; + for ( int i = 0; i < mProjects.count(); ++i ) + { + if ( mProjects[i].id() == projectId ) + { + projectIndex = i; + } + + if ( i != projectIndex && mProjects[i].projectName == trimmedName ) + { + return tr( "The project name is already taken" ); + } + } + + if ( projectIndex == -1 ) + { + return tr( "Project not found" ); + } + + projectIndexOut = projectIndex; + + return {}; +} + +void LocalProjectsManager::renameLocalProject( const QString &projectId, const QString &newName ) +{ + const QString trimmedName = newName.trimmed(); + + int projectIndex = -1; + const QString validationError = validateRename( projectId, trimmedName, projectIndex ); + + if ( !validationError.isEmpty() ) + { + CoreUtils::log( "Rename project", validationError ); + emit renameLocalProjectFinished( false ); + return; + } + + LocalProject &project = mProjects[projectIndex]; + const QString oldProjectId = project.id(); + + if ( project.projectName != trimmedName ) + { + // let listeners (e.g. ActiveProject) unload this project before it's renamed on disk + emit aboutToRenameLocalProject( oldProjectId ); + + const QString parentDir = QFileInfo( project.projectDir ).dir().absolutePath(); + const QString newProjectDir = CoreUtils::findUniquePath( parentDir + "/" + trimmedName ); + + // rename the directory first - if this fails, nothing has been changed yet, so we keep the + // project as it is and just report the failure + if ( !QDir().rename( project.projectDir, newProjectDir ) ) + { + CoreUtils::log( "Rename project", QStringLiteral( "Failed to rename directory %1 to %2" ).arg( project.projectDir, newProjectDir ) ); + emit renameLocalProjectFinished( false ); + return; + } + + // the QGIS project file now lives in the renamed directory, so rename it to match the new name too; + // this is best-effort - if it fails, the project still works, we just keep pointing to the old file name + if ( !project.qgisProjectFilePath.isEmpty() ) + { + const QString relativeFilePath = QDir( project.projectDir ).relativeFilePath( project.qgisProjectFilePath ); + const QString oldFilePath = newProjectDir + "/" + relativeFilePath; + + QFileInfo oldFileInfo( oldFilePath ); + const QString newFilePath = oldFileInfo.dir().absoluteFilePath( trimmedName + "." + oldFileInfo.suffix() ); + + if ( oldFilePath != newFilePath && QFile::rename( oldFilePath, newFilePath ) ) + { + project.qgisProjectFilePath = newFilePath; + } + else + { + project.qgisProjectFilePath = oldFilePath; + } + } + + project.projectDir = newProjectDir; + project.projectName = trimmedName; + } + + emit localProjectRenamed( oldProjectId, project ); + emit renameLocalProjectFinished( true ); +} diff --git a/core/localprojectsmanager.h b/core/localprojectsmanager.h index f90290861..b272c9ce5 100644 --- a/core/localprojectsmanager.h +++ b/core/localprojectsmanager.h @@ -53,6 +53,14 @@ class LocalProjectsManager : public QObject Q_INVOKABLE QString projectName( const QString &projectId ) const; + //! Returns an empty string if newName (trimmed) is a valid, available name for projectId, or a + //! user-facing error message otherwise. Does not touch the filesystem. + QString validateRename( const QString &projectId, const QString &newName ) const; + + //! Renames the local project's directory and main QGIS project file to newName. + //! Reports the outcome via renameLocalProjectFinished(); failures are logged, not returned. + void renameLocalProject( const QString &projectId, const QString &newName ); + /** * Returns changes of a project specified by projectId in the form : * (pending changes, features in layer survey: 10 addition, 3 updates, 1 deletion. 10 new files) @@ -71,6 +79,9 @@ class LocalProjectsManager : public QObject signals: void localProjectAdded( const LocalProject &project ); void localProjectDataChanged( const LocalProject &project ); + void localProjectRenamed( const QString &oldProjectId, const LocalProject &project ); + void renameLocalProjectFinished( bool success ); + void aboutToRenameLocalProject( const QString &projectId ); void aboutToRemoveLocalProject( const LocalProject &project ); void dataDirReloaded(); @@ -78,6 +89,9 @@ class LocalProjectsManager : public QObject private: void addProject( const QString &projectDir, const QString &projectNamespace, const QString &projectName ); + //! Same as the public validateRename(), but on success also outputs projectId's index in mProjects + QString validateRename( const QString &projectId, const QString &newName, int &projectIndexOut ) const; + QString mDataDir; //!< directory with all local projects LocalProjectsList mProjects; }; diff --git a/gallery/qml/pages/ProjectItemsPage.qml b/gallery/qml/pages/ProjectItemsPage.qml index 49ff70f19..473ef734d 100644 --- a/gallery/qml/pages/ProjectItemsPage.qml +++ b/gallery/qml/pages/ProjectItemsPage.qml @@ -48,7 +48,7 @@ MMPage { projectIsInSync: true projectSyncProgress: 1/4 - projectActionButtons: ["download", "sync", "remove"] + projectActionButtons: ["download", "sync", "remove", "rename"] state: "NeedsSync" @@ -61,6 +61,7 @@ MMPage { } onRemoveRequested: console.log("onRemoveRequested") onMigrateRequested: console.log("onMigrateRequested") + onRenameRequested: console.log("onRenameRequested") Timer { id: syncAnimator diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 19c5cdbf2..20998e3bb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -22,6 +22,7 @@ set(MM_TESTS testImageUtils testMapTools testLayerTree + testLocalProjectsManager testActiveProject testProjectChecksumCache testVariablesManager