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/projectsmodel.cpp b/app/projectsmodel.cpp index 26a58519a..044f12e71 100644 --- a/app/projectsmodel.cpp +++ b/app/projectsmodel.cpp @@ -53,6 +53,7 @@ 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::dataDirReloaded, this, &ProjectsModel::loadLocalProjects ); emit modelInitialized(); @@ -380,6 +381,32 @@ void ProjectsModel::removeLocalProject( const QString &projectId ) mLocalProjectsManager->removeLocalProject( projectId ); } +QString ProjectsModel::renameLocalProject( const QString &projectId, const QString &newName ) +{ + return 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 ); + + if ( mActiveProjectId == oldProjectId ) + { + mActiveProjectId = localProject.id(); + emit activeProjectIdChanged( mActiveProjectId ); + } +} + void ProjectsModel::migrateProject( const QString &projectId ) { int ix = projectIndexFromId( projectId ); diff --git a/app/projectsmodel.h b/app/projectsmodel.h index 900bec57e..691387940 100644 --- a/app/projectsmodel.h +++ b/app/projectsmodel.h @@ -127,6 +127,9 @@ class ProjectsModel : public QAbstractListModel //! Forwards call to LocalProjectsManager to remove local project Q_INVOKABLE void removeLocalProject( const QString &projectId ); + //! Forwards call to LocalProjectsManager to rename local project + Q_INVOKABLE QString renameLocalProject( const QString &projectId, const QString &newName ); + //! Migrates local project to mergin Q_INVOKABLE void migrateProject( const QString &projectId ); @@ -170,6 +173,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(); 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..9db3a3e4a 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: () => { + internal.projectIdToRename = projectId + renameDialog.open() + } } } @@ -306,6 +310,25 @@ Item { } } + MMProjectComponents.MMRenameProjectDialog { + id: renameDialog + + onRenameClicked: function( newName ) { + if ( !internal.projectIdToRename ) { + return + } + + const renameResult = controllerModel.renameLocalProject( internal.projectIdToRename, newName ) + + if ( !renameResult ) { + renameDialog.close() + } + else { + renameDialog.errorText = renameResult + } + } + } + MMDownloadProjectDialog { id: downloadProjectDialog @@ -314,4 +337,10 @@ Item { downloadProjectDialog.relatedProjectId = "" } } + + QtObject { + id: internal + + property string projectIdToRename: "" + } } diff --git a/app/qml/project/components/MMProjectDelegate.qml b/app/qml/project/components/MMProjectDelegate.qml index b858b0666..c945807a7 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 the local 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..662d5e0c4 --- /dev/null +++ b/app/qml/project/components/MMRenameProjectDialog.qml @@ -0,0 +1,57 @@ +/*************************************************************************** + * * + * 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 ) + + 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: () => newNameField.errorMsg = "" + } + + 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..6ae163816 --- /dev/null +++ b/app/test/testlocalprojectsmanager.cpp @@ -0,0 +1,191 @@ +/*************************************************************************** + * * + * 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 +#include +#include + +namespace +{ + //! Creates a fake local project directory containing a dummy .qgz file, so + //! LocalProjectsManager::findQgisProjectFile() has something to discover. + QString createFakeProject( const QString &dataDir, const QString &name ) + { + QString projectDir = dataDir + "/" + name; + QDir().mkpath( projectDir ); + + QFile qgzFile( projectDir + "/" + name + ".qgz" ); + qgzFile.open( QIODevice::WriteOnly ); + qgzFile.close(); + + return projectDir; + } +} + +void TestLocalProjectsManager::init() +{ + mDataDir = QDir::tempPath() + QStringLiteral( "/testLocalProjectsManager" ); + + QDir dir( mDataDir ); + if ( dir.exists() ) + dir.removeRecursively(); + + QDir().mkpath( mDataDir ); +} + +void TestLocalProjectsManager::cleanup() +{ + QDir( mDataDir ).removeRecursively(); +} + +void TestLocalProjectsManager::testRenameSuccess() +{ + createFakeProject( mDataDir, "OriginalName" ); + + LocalProjectsManager manager( mDataDir ); + QCOMPARE( manager.projects().size(), 1 ); + + QString projectId = manager.projects().first().id(); + QCOMPARE( projectId, QStringLiteral( "OriginalName" ) ); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + + QString result = manager.renameLocalProject( projectId, QStringLiteral( "NewName" ) ); + + QCOMPARE( result, QString() ); + 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() +{ + createFakeProject( mDataDir, "OriginalName" ); + + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + + QString result = manager.renameLocalProject( projectId, QStringLiteral( " " ) ); + + QVERIFY( !result.isEmpty() ); + QCOMPARE( renamedSpy.count(), 0 ); + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); +} + +void TestLocalProjectsManager::testRenameInvalidCharacters() +{ + createFakeProject( mDataDir, "OriginalName" ); + + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + + QString result = manager.renameLocalProject( projectId, QStringLiteral( "Bad/Name" ) ); + + QVERIFY( !result.isEmpty() ); + QCOMPARE( renamedSpy.count(), 0 ); + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); +} + +void TestLocalProjectsManager::testRenameNameAlreadyTaken() +{ + createFakeProject( mDataDir, "ProjectA" ); + createFakeProject( mDataDir, "ProjectB" ); + + LocalProjectsManager manager( mDataDir ); + QCOMPARE( manager.projects().size(), 2 ); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + + QString result = manager.renameLocalProject( QStringLiteral( "ProjectA" ), QStringLiteral( "ProjectB" ) ); + + QVERIFY( !result.isEmpty() ); + QCOMPARE( renamedSpy.count(), 0 ); + QVERIFY( QDir( mDataDir + "/ProjectA" ).exists() ); + QVERIFY( QDir( mDataDir + "/ProjectB" ).exists() ); +} + +void TestLocalProjectsManager::testRenameSameName() +{ + createFakeProject( mDataDir, "OriginalName" ); + + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QSignalSpy renamedSpy( &manager, &LocalProjectsManager::localProjectRenamed ); + + QString result = manager.renameLocalProject( projectId, QStringLiteral( "OriginalName" ) ); + + QCOMPARE( result, QString() ); + QCOMPARE( renamedSpy.count(), 0 ); // no-op - nothing should have been touched + QVERIFY( QDir( mDataDir + "/OriginalName" ).exists() ); +} + +void TestLocalProjectsManager::testRenameUnknownProject() +{ + LocalProjectsManager manager( mDataDir ); + QCOMPARE( manager.projects().size(), 0 ); + + QString result = manager.renameLocalProject( QStringLiteral( "does-not-exist" ), QStringLiteral( "NewName" ) ); + + QVERIFY( !result.isEmpty() ); +} + +void TestLocalProjectsManager::testRenameDirectoryCollision() +{ + createFakeProject( mDataDir, "OriginalName" ); + + 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" ); + + QString result = manager.renameLocalProject( projectId, QStringLiteral( "NewName" ) ); + + QCOMPARE( result, QString() ); + 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() +{ + createFakeProject( mDataDir, "OriginalName" ); + + LocalProjectsManager manager( mDataDir ); + QString projectId = manager.projects().first().id(); + + QString result = manager.renameLocalProject( projectId, QStringLiteral( " NewName " ) ); + + QCOMPARE( result, QString() ); + QVERIFY( QDir( mDataDir + "/NewName" ).exists() ); + + LocalProject updated = manager.projectFromProjectId( QStringLiteral( "NewName" ) ); + QVERIFY( updated.isValid() ); + QCOMPARE( updated.projectName, QStringLiteral( "NewName" ) ); +} diff --git a/app/test/testlocalprojectsmanager.h b/app/test/testlocalprojectsmanager.h new file mode 100644 index 000000000..994d85352 --- /dev/null +++ b/app/test/testlocalprojectsmanager.h @@ -0,0 +1,36 @@ +/*************************************************************************** + * * + * 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(); + + private: + QString mDataDir; +}; + +#endif // TESTLOCALPROJECTSMANAGER_H diff --git a/app/test/testmodels.cpp b/app/test/testmodels.cpp index 48682ace1..ecd56cf90 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 = QStringLiteral( "/tmp/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 = QStringLiteral( "/tmp/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 = QStringLiteral( "/tmp/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 = QStringLiteral( "/tmp/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 update activeProjectId to follow it + 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 = QStringLiteral( "/tmp/NewerName" ); + + model.onProjectRenamed( renamed.id(), renamedAgain ); + + QCOMPARE( model.activeProjectId(), renamedAgain.id() ); + QCOMPARE( activeRenameSpy.count(), 1 ); +} 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/core/localprojectsmanager.cpp b/core/localprojectsmanager.cpp index f3d134fc8..7b501292b 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,79 @@ void LocalProjectsManager::addProject( const QString &projectDir, const QString mProjects << project; emit localProjectAdded( project ); } + +QString LocalProjectsManager::renameLocalProject( const QString &projectId, const QString &newName ) +{ + 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( "A project name is already taken" ); + } + } + + if ( projectIndex == -1 ) + { + return tr( "Project not found" ); + } + + LocalProject &project = mProjects[projectIndex]; + + if ( project.projectName == trimmedName ) + { + return {}; // name did not change, nothing to rename + } + + const QString oldProjectId = project.id(); + + const QString parentDir = QFileInfo( project.projectDir ).dir().absolutePath(); + const QString newProjectDir = CoreUtils::findUniquePath( parentDir + "/" + trimmedName ); + + if ( !QDir().rename( project.projectDir, newProjectDir ) ) + { + CoreUtils::log( "Rename project", QStringLiteral( "Failed to rename directory %1 to %2" ).arg( project.projectDir, newProjectDir ) ); + return tr( "Failed to rename the project directory" ); + } + + 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 ); + return {}; +} diff --git a/core/localprojectsmanager.h b/core/localprojectsmanager.h index f90290861..e41f2b396 100644 --- a/core/localprojectsmanager.h +++ b/core/localprojectsmanager.h @@ -53,6 +53,10 @@ class LocalProjectsManager : public QObject Q_INVOKABLE QString projectName( const QString &projectId ) const; + //! Renames the local project's directory and main QGIS project file to newName. + //! Returns an empty string on success, or a user-facing error message on failure. + QString 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 +75,7 @@ class LocalProjectsManager : public QObject signals: void localProjectAdded( const LocalProject &project ); void localProjectDataChanged( const LocalProject &project ); + void localProjectRenamed( const QString &oldProjectId, const LocalProject &project ); void aboutToRemoveLocalProject( const LocalProject &project ); void dataDirReloaded(); 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