From 63ff6a015c1cde68db57ebe5c6b49fa4a3a3914b Mon Sep 17 00:00:00 2001 From: Richard Kello Date: Thu, 30 Jul 2026 13:35:10 +0200 Subject: [PATCH 1/3] Add rename local project option --- app/projectsmodel.cpp | 5 + app/projectsmodel.h | 3 + app/qml/CMakeLists.txt | 1 + app/qml/dialogs/MMRenameProjectDialog.qml | 96 +++++++++++++++++++ app/qml/project/MMProjectList.qml | 37 ++++++- .../project/components/MMProjectDelegate.qml | 8 +- core/localprojectsmanager.cpp | 60 ++++++++++++ core/localprojectsmanager.h | 2 + gallery/qml/pages/DrawerPage.qml | 14 +++ gallery/qml/pages/ProjectItemsPage.qml | 3 +- 10 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 app/qml/dialogs/MMRenameProjectDialog.qml diff --git a/app/projectsmodel.cpp b/app/projectsmodel.cpp index 26a58519a..c98613b20 100644 --- a/app/projectsmodel.cpp +++ b/app/projectsmodel.cpp @@ -380,6 +380,11 @@ 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::migrateProject( const QString &projectId ) { int ix = projectIndexFromId( projectId ); diff --git a/app/projectsmodel.h b/app/projectsmodel.h index 900bec57e..2502fa537 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 ); diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index cffff9b66..93bdc11be 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -81,6 +81,7 @@ set(MM_QML dialogs/MMPositionTrackingDialog.qml dialogs/MMProjectLimitDialog.qml dialogs/MMRemoveProjectDialog.qml + dialogs/MMRenameProjectDialog.qml dialogs/MMWelcomeToNewDesignDialog.qml dialogs/MMSplittingFailedDialog.qml dialogs/MMStorageLimitDialog.qml diff --git a/app/qml/dialogs/MMRenameProjectDialog.qml b/app/qml/dialogs/MMRenameProjectDialog.qml new file mode 100644 index 000000000..47ca58ee9 --- /dev/null +++ b/app/qml/dialogs/MMRenameProjectDialog.qml @@ -0,0 +1,96 @@ +/*************************************************************************** + * * + * 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 string relatedProjectId: "" + property string newProjectName: "" + property string renameErrorText: "" + + signal renameClicked( string newName ) + + drawerHeader.title: qsTr( "Rename the project" ) + drawerHeader.titleFont: __style.t2 + + drawerContent: Column { + width: parent.width + spacing: __style.spacing10 + + MMTextInput { + id: newNameField + + width: parent.width + textFieldBackground.color: root.renameErrorText === "" ? __style.lightGreenColor : __style.negativeUltraLightColor + textFieldBackground.border.width: root.renameErrorText === "" ? 0 : __style.width2 + textFieldBackground.border.color: root.renameErrorText === "" ? __style.polarColor : __style.negativeColor + + placeholderText: qsTr( "Enter the new name" ) + text: root.newProjectName + + onTextEdited: root.clearRenameError() + } + + Item { + width: parent.width + height: __style.row24 + + visible: true + + Row { + width: parent.width + height: parent.height + anchors.verticalCenter: parent.verticalCenter + visible: root.renameErrorText !== "" + spacing: __style.margin4 + + MMIcon { + y: parent.height / 2 - height / 2 + source: __style.errorCircleIcon + color: __style.negativeColor + size: __style.icon16 + } + + MMText { + width: parent.width - __style.icon16 - parent.spacing + text: root.renameErrorText + color: __style.grapeColor + font: __style.t4 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + } + + MMButton { + width: parent.width + + text: qsTr( "Confirm" ) + + onClicked: { + root.renameClicked( newNameField.text ) + } + } + } + + function clearRenameError() { + root.renameErrorText = "" + } + + function showRenameError( message ) { + root.renameErrorText = message + } + +} \ No newline at end of file diff --git a/app/qml/project/MMProjectList.qml b/app/qml/project/MMProjectList.qml index 7636434b9..449589aa0 100644 --- a/app/qml/project/MMProjectList.qml +++ b/app/qml/project/MMProjectList.qml @@ -97,7 +97,7 @@ Item { width: ListView.view.width height: visible ? implicitHeight : 0 - projectDisplayName: root.projectModelType === MM.ProjectsModel.WorkspaceProjectsModel ? model.ProjectName : model.ProjectFullName + projectDisplayName: model.ProjectName projectId: model.ProjectId ? model.ProjectId : "" projectDescription: model.ProjectDescription ? model.ProjectDescription : "" projectIsInSync: model.ProjectSyncPending ? model.ProjectSyncPending : false @@ -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,12 @@ Item { } onStopSyncRequested: controllerModel.stopProjectSync( projectId ) onShowChangesRequested: root.showLocalChangesRequested( projectId ) + onRenameRequested: { + renameDialog.relatedProjectId = projectId + renameDialog.newProjectName = model.ProjectName + renameDialog.clearRenameError() + renameDialog.open() + } } } @@ -306,6 +312,33 @@ Item { } } + MMRenameProjectDialog { + id: renameDialog + + onRenameClicked: function( newName ) { + if (relatedProjectId === "") { + return + } + + __inputUtils.log( + "Rename project", + "Project " + __localProjectsManager.projectName( relatedProjectId ) + " renamed to " + newName + " by " + + ( __merginApi.userInfo ? __merginApi.userInfo.username : "unknown" ) + " (" + __localProjectsManager.projectChanges( relatedProjectId ) + ")" ) + + let renameResult = controllerModel.renameLocalProject( relatedProjectId, newName ) + + if ( renameResult === "" ) { + renameDialog.relatedProjectId = "" + renameDialog.clearRenameError() + renameDialog.close() + } + else { + renameDialog.newProjectName = "" + renameDialog.showRenameError( renameResult ) + } + } + } + MMDownloadProjectDialog { id: downloadProjectDialog 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/core/localprojectsmanager.cpp b/core/localprojectsmanager.cpp index f3d134fc8..4a898855c 100644 --- a/core/localprojectsmanager.cpp +++ b/core/localprojectsmanager.cpp @@ -15,6 +15,12 @@ #include #include +#include + +// Local (never synced to server) override for a project's display name, keyed by its +// stable projectDir. Stored outside of the Mergin metadata cache since that file is +// wholesale overwritten with the server's response on every sync (see MerginApi::writeData). +static const QString sLocalProjectNameSettingsGroup = QStringLiteral( "localProjectNames" ); LocalProjectsManager::LocalProjectsManager( const QString &dataDir ) : mDataDir( dataDir ) @@ -25,6 +31,10 @@ LocalProjectsManager::LocalProjectsManager( const QString &dataDir ) void LocalProjectsManager::reloadDataDir() { mProjects.clear(); + + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + QStringList entryList = QDir( mDataDir ).entryList( QDir::NoDotAndDotDot | QDir::Dirs ); for ( const QString &folderName : entryList ) { @@ -44,9 +54,18 @@ void LocalProjectsManager::reloadDataDir() info.projectName = folderName; } + // A local rename overrides whatever name we just resolved above. + const QString customName = settings.value( sLocalProjectNameSettingsGroup + "/" + info.projectDir ).toString(); + if ( !customName.isEmpty() ) + { + info.projectName = customName; + } + mProjects << info; } + settings.endGroup(); + QString msg = QString( "Found %1 local projects in %2" ).arg( mProjects.size() ).arg( mDataDir ); CoreUtils::log( "Local projects", msg ); emit dataDirReloaded(); @@ -115,6 +134,11 @@ void LocalProjectsManager::removeLocalProject( const QString &projectId ) { emit aboutToRemoveLocalProject( mProjects[i] ); + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + settings.remove( sLocalProjectNameSettingsGroup + "/" + mProjects[i].projectDir ); + settings.endGroup(); + CoreUtils::removeDir( mProjects[i].projectDir ); mProjects.removeAt( i ); @@ -249,3 +273,39 @@ void LocalProjectsManager::addProject( const QString &projectDir, const QString mProjects << project; emit localProjectAdded( project ); } + +QString LocalProjectsManager::renameLocalProject( const QString &projectId, const QString &newName ) +{ + if ( newName.trimmed().isEmpty() ) + { + return tr( "The project name cannot be empty" ); + } + + if ( !CoreUtils::isValidName( newName ) ) + { + 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 == newName ) + { + return tr( "A project name is already taken" ); + } + } + + QSettings settings; + settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); + settings.setValue( sLocalProjectNameSettingsGroup + "/" + mProjects[projectIndex].projectDir, newName ); + settings.endGroup(); + + mProjects[projectIndex].projectName = newName; + emit localProjectDataChanged( mProjects[projectIndex] ); + return QString(); +} diff --git a/core/localprojectsmanager.h b/core/localprojectsmanager.h index f90290861..30282ec93 100644 --- a/core/localprojectsmanager.h +++ b/core/localprojectsmanager.h @@ -53,6 +53,8 @@ class LocalProjectsManager : public QObject Q_INVOKABLE QString projectName( const QString &projectId ) const; + Q_INVOKABLE 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) diff --git a/gallery/qml/pages/DrawerPage.qml b/gallery/qml/pages/DrawerPage.qml index 47a0aab1f..ff0fa1ab4 100644 --- a/gallery/qml/pages/DrawerPage.qml +++ b/gallery/qml/pages/DrawerPage.qml @@ -48,6 +48,11 @@ Page { onClicked: removeProjectDialog.open() } + Button { + text: "MMRenameProjectDialog" + onClicked: renameProjectDialog.open() + } + Button { text: "MMDownloadProjectDialog" onClicked: downloadProjectDialog.open() @@ -251,6 +256,15 @@ Page { onRemoveClicked: console.log("on remove clicked") } + MMRenameProjectDialog { + id: renameProjectDialog + + relatedProjectId: "Cool Project" + newProjectName: "Even cooler Project" + + onRenameClicked: console.log("on rename clicked") + } + MMProjectLimitDialog { id: projectLimitDialog 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 From b83286064ec761279fdaf02711a54b482f18f037 Mon Sep 17 00:00:00 2001 From: Richard Kello Date: Mon, 24 Aug 2026 20:20:43 +0200 Subject: [PATCH 2/3] Update rename feature based on review --- app/projectsmodel.cpp | 27 ++++++ app/projectsmodel.h | 1 + app/qml/CMakeLists.txt | 2 +- app/qml/project/MMProjectList.qml | 25 ++---- .../components}/MMRenameProjectDialog.qml | 50 +++++------ core/localprojectsmanager.cpp | 85 ++++++++++++------- core/localprojectsmanager.h | 3 +- gallery/qml/pages/DrawerPage.qml | 14 --- 8 files changed, 112 insertions(+), 95 deletions(-) rename app/qml/{dialogs => project/components}/MMRenameProjectDialog.qml (64%) diff --git a/app/projectsmodel.cpp b/app/projectsmodel.cpp index c98613b20..920e33417 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(); @@ -385,6 +386,32 @@ QString ProjectsModel::renameLocalProject( const QString &projectId, const QStri 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; + + if ( project.isMergin() ) + { + project.mergin.status = ProjectStatus::projectStatus( project, mBackend->supportsSelectiveSync() ); + } + + 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 2502fa537..691387940 100644 --- a/app/projectsmodel.h +++ b/app/projectsmodel.h @@ -173,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 93bdc11be..188bfcaa4 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -81,7 +81,6 @@ set(MM_QML dialogs/MMPositionTrackingDialog.qml dialogs/MMProjectLimitDialog.qml dialogs/MMRemoveProjectDialog.qml - dialogs/MMRenameProjectDialog.qml dialogs/MMWelcomeToNewDesignDialog.qml dialogs/MMSplittingFailedDialog.qml dialogs/MMStorageLimitDialog.qml @@ -184,6 +183,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 449589aa0..768a35b24 100644 --- a/app/qml/project/MMProjectList.qml +++ b/app/qml/project/MMProjectList.qml @@ -26,6 +26,7 @@ Item { property string searchText: "" property int spacing: 0 property bool activeProjectAlwaysFirst: false + property string projectIdToRename: "" property alias projectsProxyModel: viewModel property alias projectsModel: controllerModel property alias listHeader: listview.header @@ -97,7 +98,7 @@ Item { width: ListView.view.width height: visible ? implicitHeight : 0 - projectDisplayName: model.ProjectName + projectDisplayName: root.projectModelType === MM.ProjectsModel.WorkspaceProjectsModel ? model.ProjectName : model.ProjectFullName projectId: model.ProjectId ? model.ProjectId : "" projectDescription: model.ProjectDescription ? model.ProjectDescription : "" projectIsInSync: model.ProjectSyncPending ? model.ProjectSyncPending : false @@ -177,9 +178,7 @@ Item { onStopSyncRequested: controllerModel.stopProjectSync( projectId ) onShowChangesRequested: root.showLocalChangesRequested( projectId ) onRenameRequested: { - renameDialog.relatedProjectId = projectId - renameDialog.newProjectName = model.ProjectName - renameDialog.clearRenameError() + root.projectIdToRename = projectId renameDialog.open() } } @@ -312,29 +311,21 @@ Item { } } - MMRenameProjectDialog { + MMProjectComponents.MMRenameProjectDialog { id: renameDialog onRenameClicked: function( newName ) { - if (relatedProjectId === "") { + if ( !root.projectIdToRename ) { return } - __inputUtils.log( - "Rename project", - "Project " + __localProjectsManager.projectName( relatedProjectId ) + " renamed to " + newName + " by " + - ( __merginApi.userInfo ? __merginApi.userInfo.username : "unknown" ) + " (" + __localProjectsManager.projectChanges( relatedProjectId ) + ")" ) - - let renameResult = controllerModel.renameLocalProject( relatedProjectId, newName ) + const renameResult = controllerModel.renameLocalProject( root.projectIdToRename, newName ) - if ( renameResult === "" ) { - renameDialog.relatedProjectId = "" - renameDialog.clearRenameError() + if ( !renameResult ) { renameDialog.close() } else { - renameDialog.newProjectName = "" - renameDialog.showRenameError( renameResult ) + renameDialog.errorText = renameResult } } } diff --git a/app/qml/dialogs/MMRenameProjectDialog.qml b/app/qml/project/components/MMRenameProjectDialog.qml similarity index 64% rename from app/qml/dialogs/MMRenameProjectDialog.qml rename to app/qml/project/components/MMRenameProjectDialog.qml index 47ca58ee9..ff37b8974 100644 --- a/app/qml/dialogs/MMRenameProjectDialog.qml +++ b/app/qml/project/components/MMRenameProjectDialog.qml @@ -9,52 +9,54 @@ import QtQuick -import "../components" -import "../inputs" +import "../../components" +import "../../inputs" MMDrawer { id: root - property string relatedProjectId: "" - property string newProjectName: "" - property string renameErrorText: "" + property string errorText: "" signal renameClicked( string newName ) - drawerHeader.title: qsTr( "Rename the project" ) + drawerHeader.title: qsTr( "Rename project" ) drawerHeader.titleFont: __style.t2 + onOpened: { + root.errorText = "" + newNameField.text = "" + } + drawerContent: Column { width: parent.width - spacing: __style.spacing10 + spacing: 0 MMTextInput { id: newNameField width: parent.width - textFieldBackground.color: root.renameErrorText === "" ? __style.lightGreenColor : __style.negativeUltraLightColor - textFieldBackground.border.width: root.renameErrorText === "" ? 0 : __style.width2 - textFieldBackground.border.color: root.renameErrorText === "" ? __style.polarColor : __style.negativeColor + textFieldBackground.color: root.errorText === "" ? __style.lightGreenColor : __style.negativeUltraLightColor + textFieldBackground.border.width: root.errorText === "" ? 0 : __style.width2 + textFieldBackground.border.color: root.errorText === "" ? __style.polarColor : __style.negativeColor placeholderText: qsTr( "Enter the new name" ) - text: root.newProjectName - onTextEdited: root.clearRenameError() + onTextEdited: root.errorText = "" } + // Fixed-height slot so the drawer does not grow/shrink when the error message appears. Item { width: parent.width - height: __style.row24 - - visible: true + height: __style.spacing40 Row { - width: parent.width - height: parent.height anchors.verticalCenter: parent.verticalCenter - visible: root.renameErrorText !== "" + + width: parent.width spacing: __style.margin4 + visible: root.errorText !== "" + MMIcon { y: parent.height / 2 - height / 2 source: __style.errorCircleIcon @@ -64,14 +66,13 @@ MMDrawer { MMText { width: parent.width - __style.icon16 - parent.spacing - text: root.renameErrorText + text: root.errorText color: __style.grapeColor font: __style.t4 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } - } MMButton { @@ -84,13 +85,4 @@ MMDrawer { } } } - - function clearRenameError() { - root.renameErrorText = "" - } - - function showRenameError( message ) { - root.renameErrorText = message - } - } \ No newline at end of file diff --git a/core/localprojectsmanager.cpp b/core/localprojectsmanager.cpp index 4a898855c..7b501292b 100644 --- a/core/localprojectsmanager.cpp +++ b/core/localprojectsmanager.cpp @@ -15,12 +15,8 @@ #include #include -#include - -// Local (never synced to server) override for a project's display name, keyed by its -// stable projectDir. Stored outside of the Mergin metadata cache since that file is -// wholesale overwritten with the server's response on every sync (see MerginApi::writeData). -static const QString sLocalProjectNameSettingsGroup = QStringLiteral( "localProjectNames" ); +#include +#include LocalProjectsManager::LocalProjectsManager( const QString &dataDir ) : mDataDir( dataDir ) @@ -32,9 +28,6 @@ void LocalProjectsManager::reloadDataDir() { mProjects.clear(); - QSettings settings; - settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); - QStringList entryList = QDir( mDataDir ).entryList( QDir::NoDotAndDotDot | QDir::Dirs ); for ( const QString &folderName : entryList ) { @@ -54,18 +47,9 @@ void LocalProjectsManager::reloadDataDir() info.projectName = folderName; } - // A local rename overrides whatever name we just resolved above. - const QString customName = settings.value( sLocalProjectNameSettingsGroup + "/" + info.projectDir ).toString(); - if ( !customName.isEmpty() ) - { - info.projectName = customName; - } - mProjects << info; } - settings.endGroup(); - QString msg = QString( "Found %1 local projects in %2" ).arg( mProjects.size() ).arg( mDataDir ); CoreUtils::log( "Local projects", msg ); emit dataDirReloaded(); @@ -134,11 +118,6 @@ void LocalProjectsManager::removeLocalProject( const QString &projectId ) { emit aboutToRemoveLocalProject( mProjects[i] ); - QSettings settings; - settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); - settings.remove( sLocalProjectNameSettingsGroup + "/" + mProjects[i].projectDir ); - settings.endGroup(); - CoreUtils::removeDir( mProjects[i].projectDir ); mProjects.removeAt( i ); @@ -276,12 +255,14 @@ void LocalProjectsManager::addProject( const QString &projectDir, const QString QString LocalProjectsManager::renameLocalProject( const QString &projectId, const QString &newName ) { - if ( newName.trimmed().isEmpty() ) + const QString trimmedName = newName.trimmed(); + + if ( trimmedName.isEmpty() ) { return tr( "The project name cannot be empty" ); } - if ( !CoreUtils::isValidName( newName ) ) + if ( !CoreUtils::isValidName( trimmedName ) ) { return tr( "The project name contains invalid characters" ); } @@ -294,18 +275,56 @@ QString LocalProjectsManager::renameLocalProject( const QString &projectId, cons projectIndex = i; } - if ( i != projectIndex && mProjects[i].projectName == newName ) + if ( i != projectIndex && mProjects[i].projectName == trimmedName ) { return tr( "A project name is already taken" ); } } - QSettings settings; - settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); - settings.setValue( sLocalProjectNameSettingsGroup + "/" + mProjects[projectIndex].projectDir, newName ); - settings.endGroup(); + if ( projectIndex == -1 ) + { + return tr( "Project not found" ); + } - mProjects[projectIndex].projectName = newName; - emit localProjectDataChanged( mProjects[projectIndex] ); - return QString(); + 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 30282ec93..ca02b0054 100644 --- a/core/localprojectsmanager.h +++ b/core/localprojectsmanager.h @@ -53,7 +53,7 @@ class LocalProjectsManager : public QObject Q_INVOKABLE QString projectName( const QString &projectId ) const; - Q_INVOKABLE QString renameLocalProject( const QString &projectId, const QString &newName ); + QString renameLocalProject( const QString &projectId, const QString &newName ); /** * Returns changes of a project specified by projectId in the form : @@ -73,6 +73,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/DrawerPage.qml b/gallery/qml/pages/DrawerPage.qml index ff0fa1ab4..47a0aab1f 100644 --- a/gallery/qml/pages/DrawerPage.qml +++ b/gallery/qml/pages/DrawerPage.qml @@ -48,11 +48,6 @@ Page { onClicked: removeProjectDialog.open() } - Button { - text: "MMRenameProjectDialog" - onClicked: renameProjectDialog.open() - } - Button { text: "MMDownloadProjectDialog" onClicked: downloadProjectDialog.open() @@ -256,15 +251,6 @@ Page { onRemoveClicked: console.log("on remove clicked") } - MMRenameProjectDialog { - id: renameProjectDialog - - relatedProjectId: "Cool Project" - newProjectName: "Even cooler Project" - - onRenameClicked: console.log("on rename clicked") - } - MMProjectLimitDialog { id: projectLimitDialog From c0cabe118498e2dd1dc5bddb15f41e4eda286744 Mon Sep 17 00:00:00 2001 From: Richard Kello Date: Thu, 3 Sep 2026 18:01:56 +0200 Subject: [PATCH 3/3] Add unit tests and update code --- app/CMakeLists.txt | 2 + app/projectsmodel.cpp | 5 - app/qml/project/MMProjectList.qml | 15 +- .../components/MMRenameProjectDialog.qml | 51 +---- app/test/inputtests.cpp | 6 + app/test/testlocalprojectsmanager.cpp | 191 ++++++++++++++++++ app/test/testlocalprojectsmanager.h | 36 ++++ app/test/testmodels.cpp | 94 +++++++++ app/test/testmodels.h | 2 + core/localprojectsmanager.h | 2 + test/CMakeLists.txt | 1 + 11 files changed, 354 insertions(+), 51 deletions(-) create mode 100644 app/test/testlocalprojectsmanager.cpp create mode 100644 app/test/testlocalprojectsmanager.h diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index a4ec70413..88b98fedf 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -221,6 +221,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 @@ -248,6 +249,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 920e33417..044f12e71 100644 --- a/app/projectsmodel.cpp +++ b/app/projectsmodel.cpp @@ -397,11 +397,6 @@ void ProjectsModel::onProjectRenamed( const QString &oldProjectId, const LocalPr project.local = localProject; - if ( project.isMergin() ) - { - project.mergin.status = ProjectStatus::projectStatus( project, mBackend->supportsSelectiveSync() ); - } - QModelIndex editIndex = index( ix ); emit dataChanged( editIndex, editIndex ); diff --git a/app/qml/project/MMProjectList.qml b/app/qml/project/MMProjectList.qml index 768a35b24..9db3a3e4a 100644 --- a/app/qml/project/MMProjectList.qml +++ b/app/qml/project/MMProjectList.qml @@ -26,7 +26,6 @@ Item { property string searchText: "" property int spacing: 0 property bool activeProjectAlwaysFirst: false - property string projectIdToRename: "" property alias projectsProxyModel: viewModel property alias projectsModel: controllerModel property alias listHeader: listview.header @@ -177,8 +176,8 @@ Item { } onStopSyncRequested: controllerModel.stopProjectSync( projectId ) onShowChangesRequested: root.showLocalChangesRequested( projectId ) - onRenameRequested: { - root.projectIdToRename = projectId + onRenameRequested: () => { + internal.projectIdToRename = projectId renameDialog.open() } } @@ -315,11 +314,11 @@ Item { id: renameDialog onRenameClicked: function( newName ) { - if ( !root.projectIdToRename ) { + if ( !internal.projectIdToRename ) { return } - const renameResult = controllerModel.renameLocalProject( root.projectIdToRename, newName ) + const renameResult = controllerModel.renameLocalProject( internal.projectIdToRename, newName ) if ( !renameResult ) { renameDialog.close() @@ -338,4 +337,10 @@ Item { downloadProjectDialog.relatedProjectId = "" } } + + QtObject { + id: internal + + property string projectIdToRename: "" + } } diff --git a/app/qml/project/components/MMRenameProjectDialog.qml b/app/qml/project/components/MMRenameProjectDialog.qml index ff37b8974..662d5e0c4 100644 --- a/app/qml/project/components/MMRenameProjectDialog.qml +++ b/app/qml/project/components/MMRenameProjectDialog.qml @@ -15,68 +15,37 @@ import "../../inputs" MMDrawer { id: root - property string errorText: "" + property alias errorText: newNameField.errorMsg signal renameClicked( string newName ) drawerHeader.title: qsTr( "Rename project" ) drawerHeader.titleFont: __style.t2 - onOpened: { - root.errorText = "" + onAboutToShow: () => { + newNameField.errorMsg = "" newNameField.text = "" } drawerContent: Column { + id: contentColumn + width: parent.width - spacing: 0 + spacing: newNameField.errorMsg ? __style.margin12 : __style.spacing40 MMTextInput { id: newNameField - width: parent.width - textFieldBackground.color: root.errorText === "" ? __style.lightGreenColor : __style.negativeUltraLightColor - textFieldBackground.border.width: root.errorText === "" ? 0 : __style.width2 - textFieldBackground.border.color: root.errorText === "" ? __style.polarColor : __style.negativeColor + width: contentColumn.width + textFieldBackground.color: __style.lightGreenColor placeholderText: qsTr( "Enter the new name" ) - onTextEdited: root.errorText = "" - } - - // Fixed-height slot so the drawer does not grow/shrink when the error message appears. - Item { - width: parent.width - height: __style.spacing40 - - Row { - anchors.verticalCenter: parent.verticalCenter - - width: parent.width - spacing: __style.margin4 - - visible: root.errorText !== "" - - MMIcon { - y: parent.height / 2 - height / 2 - source: __style.errorCircleIcon - color: __style.negativeColor - size: __style.icon16 - } - - MMText { - width: parent.width - __style.icon16 - parent.spacing - text: root.errorText - color: __style.grapeColor - font: __style.t4 - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - } + onTextEdited: () => newNameField.errorMsg = "" } MMButton { - width: parent.width + width: contentColumn.width text: qsTr( "Confirm" ) 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.h b/core/localprojectsmanager.h index ca02b0054..e41f2b396 100644 --- a/core/localprojectsmanager.h +++ b/core/localprojectsmanager.h @@ -53,6 +53,8 @@ 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 ); /** 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