From 1534253e5487450ef8df22fdf0187d4f08c109d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 17 Aug 2026 22:42:52 -0300 Subject: [PATCH] Add cross-directory filesystem move support - enable efsw cross-directory move reporting in ecode - transfer FileSystemModel nodes between materialized parents - preserve selections, descendant paths, and lazy destination branches - update open document paths when parent directories move - fix persistent indexes across insert, delete, and move operations - clear stale tree-view metadata before model nodes are deleted - safely handle getRealPath() failures for stale paths - add model operation and filesystem move regression tests - update efsw and SOIL2 submodules --- include/eepp/system/fileinfo.hpp | 6 + include/eepp/ui/abstract/uiabstractview.hpp | 2 + include/eepp/ui/models/filesystemmodel.hpp | 2 +- include/eepp/ui/models/model.hpp | 11 + .../eepp/ui/models/persistentmodelindex.hpp | 2 +- include/eepp/ui/uitreeview.hpp | 2 + src/eepp/system/fileinfo.cpp | 74 ++++- src/eepp/system/filesystem.cpp | 14 +- src/eepp/ui/models/filesystemmodel.cpp | 107 +++---- src/eepp/ui/models/model.cpp | 236 +++++++-------- src/eepp/ui/uitreeview.cpp | 4 + src/tests/unit_tests/fileinfo_tests.cpp | 74 +++++ .../unit_tests/modeloperations_tests.cpp | 272 ++++++++++++++++++ src/thirdparty/SOIL2 | 2 +- src/thirdparty/efsw | 2 +- src/tools/ecode/ecode.cpp | 3 +- src/tools/ecode/filesystemlistener.cpp | 28 +- 17 files changed, 626 insertions(+), 215 deletions(-) create mode 100644 src/tests/unit_tests/fileinfo_tests.cpp create mode 100644 src/tests/unit_tests/modeloperations_tests.cpp diff --git a/include/eepp/system/fileinfo.hpp b/include/eepp/system/fileinfo.hpp index 0cfe6b6b7..b06eb629e 100644 --- a/include/eepp/system/fileinfo.hpp +++ b/include/eepp/system/fileinfo.hpp @@ -74,6 +74,10 @@ class EE_API FileInfo { const Uint64& getInode() const; + const Uint64& getDevice() const; + + const Uint64& getLinkCount() const; + bool isUninitialized() const; std::string getExtension( const bool& lowerExt = true ) const; @@ -87,6 +91,8 @@ class EE_API FileInfo { Uint32 mGroupId{ 0 }; Uint32 mPermissions{ 0 }; Uint64 mInode{ 0 }; + Uint64 mDevice{ 0 }; + Uint64 mLinkCount{ 0 }; }; typedef std::map FileInfoMap; diff --git a/include/eepp/ui/abstract/uiabstractview.hpp b/include/eepp/ui/abstract/uiabstractview.hpp index 3fcce3d5b..4cf81e5aa 100644 --- a/include/eepp/ui/abstract/uiabstractview.hpp +++ b/include/eepp/ui/abstract/uiabstractview.hpp @@ -137,6 +137,8 @@ class EE_API UIAbstractView : public UIScrollableWidget { virtual void onModelSelectionChange(); + virtual void onModelIndexDeleted( const void* ) {} + void modelUpdate( unsigned flags ); UIAbstractView( const std::string& tag ); diff --git a/include/eepp/ui/models/filesystemmodel.hpp b/include/eepp/ui/models/filesystemmodel.hpp index 37d8080e6..4a39ba4e5 100644 --- a/include/eepp/ui/models/filesystemmodel.hpp +++ b/include/eepp/ui/models/filesystemmodel.hpp @@ -228,7 +228,7 @@ class EE_API FileSystemModel : public Model { const DisplayConfig& displayConfig, Translator* translator, std::shared_ptr threadPool ); - size_t getFileIndex( Node* parent, const FileInfo& file ); + size_t getFileIndex( Node* parent, const FileInfo& file, const Node* excludedNode = nullptr ); bool handleFileEventLocked( const FileEvent& event ); diff --git a/include/eepp/ui/models/model.hpp b/include/eepp/ui/models/model.hpp index ec0c30ca5..af95e640c 100644 --- a/include/eepp/ui/models/model.hpp +++ b/include/eepp/ui/models/model.hpp @@ -174,6 +174,12 @@ class EE_API Model { enum class Direction { Row, Column }; struct Operation { + struct PersistentMove { + ModelIndex index; + ModelIndex targetParent; + int targetDimension; + }; + OperationType type{ OperationType::Invalid }; Direction direction{ Direction::Row }; ModelIndex sourceParent; @@ -181,6 +187,7 @@ class EE_API Model { int last{ 0 }; ModelIndex targetParent; int target{ 0 }; + std::vector persistentMoves; Operation( OperationType type ) : type( type ) {} @@ -206,6 +213,10 @@ class EE_API Model { void handleInsert( Operation const& ); void handleMove( Operation const& ); void handleDelete( Operation const& ); + void saveMovedIndices( Operation& ); + void notifyIndexDeleted( const void* internalData ) const; + void applyPersistentIndexChanges( + const std::vector>& indexChanges ); template void saveDeletedIndices( ModelIndex const& parent, int first, int last ); diff --git a/include/eepp/ui/models/persistentmodelindex.hpp b/include/eepp/ui/models/persistentmodelindex.hpp index 4da67d95c..06c5dbd38 100644 --- a/include/eepp/ui/models/persistentmodelindex.hpp +++ b/include/eepp/ui/models/persistentmodelindex.hpp @@ -18,7 +18,7 @@ class PersistentHandle { ModelIndex mIndex; }; -class PersistentModelIndex { +class EE_API PersistentModelIndex { public: PersistentModelIndex() {} PersistentModelIndex( ModelIndex const& ); diff --git a/include/eepp/ui/uitreeview.hpp b/include/eepp/ui/uitreeview.hpp index 07974a1bf..d55336317 100644 --- a/include/eepp/ui/uitreeview.hpp +++ b/include/eepp/ui/uitreeview.hpp @@ -165,6 +165,8 @@ class EE_API UITreeView : public UIAbstractTableView { virtual void onColumnSizeChange( const size_t& colIndex, bool fromUserInteraction = false ); + virtual void onModelIndexDeleted( const void* internalData ); + virtual UIWidget* updateCell( const Vector2& posIndex, const ModelIndex& index, const size_t& indentLevel, const Float& yOffset ); diff --git a/src/eepp/system/fileinfo.cpp b/src/eepp/system/fileinfo.cpp index ac3aa255d..784bfd144 100644 --- a/src/eepp/system/fileinfo.cpp +++ b/src/eepp/system/fileinfo.cpp @@ -15,6 +15,10 @@ #include #include +#if EE_PLATFORM == EE_PLATFORM_WIN +#include +#endif + #ifdef EE_COMPILER_MSVC #ifndef S_ISDIR #define S_ISDIR( f ) ( ( f ) & _S_IFDIR ) @@ -42,6 +46,43 @@ namespace EE { namespace System { +#if EE_PLATFORM == EE_PLATFORM_WIN +static void getWindowsFileIdentity( const std::string& filePath, Uint64& device, Uint64& inode, + Uint64& linkCount ) { + wchar_t stackPath[512]; + const int pathLength = static_cast( filePath.size() ); + const int wideLength = + MultiByteToWideChar( CP_UTF8, 0, filePath.data(), pathLength, nullptr, 0 ); + if ( wideLength <= 0 ) + return; + + std::wstring heapPath; + wchar_t* widePath = stackPath; + if ( static_cast( wideLength + 1 ) > sizeof( stackPath ) / sizeof( *stackPath ) ) { + heapPath.resize( wideLength + 1 ); + widePath = heapPath.data(); + } + if ( MultiByteToWideChar( CP_UTF8, 0, filePath.data(), pathLength, widePath, wideLength ) != + wideLength ) + return; + widePath[wideLength] = L'\0'; + + HANDLE handle = CreateFileW( widePath, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr ); + if ( handle == INVALID_HANDLE_VALUE ) + return; + + BY_HANDLE_FILE_INFORMATION info; + if ( GetFileInformationByHandle( handle, &info ) ) { + device = info.dwVolumeSerialNumber; + inode = static_cast( info.nFileIndexHigh ) << 32 | info.nFileIndexLow; + linkCount = info.nNumberOfLinks; + } + CloseHandle( handle ); +} +#endif + bool FileInfo::exists( const std::string& filePath ) { FileInfo fi( filePath ); return fi.exists(); @@ -53,11 +94,7 @@ bool FileInfo::isLink( const std::string& filePath ) { } bool FileInfo::inodeSupported() { -#if EE_PLATFORM != EE_PLATFORM_WIN return true; -#else - return false; -#endif } FileInfo::FileInfo() : @@ -97,7 +134,9 @@ FileInfo::FileInfo( const FileInfo& other ) : mOwnerId( other.mOwnerId ), mGroupId( other.mGroupId ), mPermissions( other.mPermissions ), - mInode( other.mInode ) {} + mInode( other.mInode ), + mDevice( other.mDevice ), + mLinkCount( other.mLinkCount ) {} void FileInfo::getInfo() { #if EE_PLATFORM == EE_PLATFORM_WIN @@ -129,6 +168,11 @@ void FileInfo::getInfo() { mGroupId = st.st_gid; mPermissions = st.st_mode; mInode = st.st_ino; + mDevice = st.st_dev; + mLinkCount = st.st_nlink; +#if EE_PLATFORM == EE_PLATFORM_WIN + getWindowsFileIdentity( std::string( fp ), mDevice, mInode, mLinkCount ); +#endif } if ( isDirectory() ) @@ -153,6 +197,11 @@ void FileInfo::getRealInfo() { mGroupId = st.st_gid; mPermissions = st.st_mode; mInode = st.st_ino; + mDevice = st.st_dev; + mLinkCount = st.st_nlink; +#if EE_PLATFORM == EE_PLATFORM_WIN + getWindowsFileIdentity( mFilepath, mDevice, mInode, mLinkCount ); +#endif } if ( isDirectory() ) @@ -197,6 +246,14 @@ const Uint64& FileInfo::getInode() const { return mInode; } +const Uint64& FileInfo::getDevice() const { + return mDevice; +} + +const Uint64& FileInfo::getLinkCount() const { + return mLinkCount; +} + bool FileInfo::isUninitialized() const { return mModificationTime == 0; } @@ -208,7 +265,8 @@ std::string FileInfo::getExtension( const bool& lowerExt ) const { bool FileInfo::operator==( const FileInfo& Other ) const { return ( mModificationTime == Other.mModificationTime && mSize == Other.mSize && mOwnerId == Other.mOwnerId && mGroupId == Other.mGroupId && - mPermissions == Other.mPermissions && mInode == Other.mInode ); + mPermissions == Other.mPermissions && mDevice == Other.mDevice && + mInode == Other.mInode ); } bool FileInfo::isDirectory() const { @@ -286,6 +344,8 @@ FileInfo& FileInfo::operator=( const FileInfo& other ) { this->mOwnerId = other.mOwnerId; this->mPermissions = other.mPermissions; this->mInode = other.mInode; + this->mDevice = other.mDevice; + this->mLinkCount = other.mLinkCount; return *this; } @@ -299,7 +359,7 @@ bool FileInfo::isExecutable() const { } bool FileInfo::sameInode( const FileInfo& other ) const { - return inodeSupported() && mInode == other.mInode; + return inodeSupported() && mInode != 0 && mDevice == other.mDevice && mInode == other.mInode; } bool FileInfo::operator!=( const FileInfo& other ) const { diff --git a/src/eepp/system/filesystem.cpp b/src/eepp/system/filesystem.cpp index 688ad9820..efb2c3e05 100644 --- a/src/eepp/system/filesystem.cpp +++ b/src/eepp/system/filesystem.cpp @@ -383,13 +383,17 @@ std::string FileSystem::getRealPath( const std::string& path ) { std::string realPath; #ifdef EE_PLATFORM_POSIX char dir[PATH_MAX]; - realpath( path.c_str(), &dir[0] ); - realPath = std::string( dir ); + if ( realpath( path.c_str(), &dir[0] ) ) + realPath = std::string( dir ); + else + realPath = path; #elif EE_PLATFORM == EE_PLATFORM_WIN wchar_t dir[_MAX_PATH + 1]; - GetFullPathNameW( String::fromUtf8( path ).toWideString().c_str(), _MAX_PATH, &dir[0], - nullptr ); - realPath = String( dir ).toUtf8(); + if ( GetFullPathNameW( String::fromUtf8( path ).toWideString().c_str(), _MAX_PATH, &dir[0], + nullptr ) ) + realPath = String( dir ).toUtf8(); + else + realPath = path; #else #warning FileSystem::getRealPath() not implemented on this platform. #endif diff --git a/src/eepp/ui/models/filesystemmodel.cpp b/src/eepp/ui/models/filesystemmodel.cpp index 4818b8256..f98ab1a15 100644 --- a/src/eepp/ui/models/filesystemmodel.cpp +++ b/src/eepp/ui/models/filesystemmodel.cpp @@ -641,11 +641,14 @@ void FileSystemModel::setPreviouslySelectedIndex( const ModelIndex& previouslySe mPreviouslySelectedIndex = previouslySelectedIndex; } -size_t FileSystemModel::getFileIndex( Node* parent, const FileInfo& file ) { +size_t FileSystemModel::getFileIndex( Node* parent, const FileInfo& file, + const Node* excludedNode ) { std::vector files; files.reserve( parent->mChildren.size() + 1 ); for ( Node* nodeFile : parent->mChildren ) { + if ( nodeFile == excludedNode ) + continue; files.emplace_back( nodeFile->info() ); if ( nodeFile->info().getFileName() == file.getFileName() ) @@ -777,6 +780,13 @@ bool FileSystemModel::handleFileEventLocked( const FileEvent& event ) { } ); if ( beginDeleteRows( index.parent(), index.row(), index.row() ) ) { + auto notifyDescendantsDeleted = [&]( auto&& notify, const Node* node ) -> void { + for ( const Node* childNode : node->mChildren ) { + notifyIndexDeleted( childNode ); + notify( notify, childNode ); + } + }; + notifyDescendantsDeleted( notifyDescendantsDeleted, child ); { Lock l( mResourceLock ); eeDelete( parent->mChildren[index.row()] ); @@ -813,11 +823,14 @@ bool FileSystemModel::handleFileEventLocked( const FileEvent& event ) { } case FileSystemEventType::Moved: { FileInfo file( event.directory + event.filename, false ); + const std::string oldFilePath = FileSystem::isRelativePath( event.oldFilename ) + ? event.directory + event.oldFilename + : event.oldFilename; if ( !file.exists() ) return false; - auto* node = getNodeFromPath( event.directory + event.oldFilename, false, false ); + auto* node = getNodeFromPath( oldFilePath, false, false ); if ( !node ) { return handleFileEventLocked( { FileSystemEventType::Add, event.directory, event.filename } ); @@ -826,89 +839,81 @@ bool FileSystemModel::handleFileEventLocked( const FileEvent& event ) { ModelIndex index = node->index( *this, 0 ); if ( !index.isValid() ) return false; + ModelIndex sourceParentIndex = index.parent(); - Node* parent = node->mParent; - if ( !parent ) + Node* sourceParent = node->mParent; + if ( !sourceParent ) return false; if ( ( getMode() == Mode::DirectoriesOnly && !file.isDirectory() ) ) return false; if ( !node->info().isHidden() && getDisplayConfig().ignoreHidden && file.isHidden() ) { - return handleFileEventLocked( - { FileSystemEventType::Delete, event.directory, event.oldFilename } ); + return handleFileEventLocked( { FileSystemEventType::Delete, "", oldFilePath } ); } const auto& displayCfg = getDisplayConfig(); if ( displayCfg.fileIsVisibleFn && !displayCfg.fileIsVisibleFn( file.getFilepath() ) ) { - return handleFileEventLocked( - { FileSystemEventType::Delete, event.directory, event.oldFilename } ); + return handleFileEventLocked( { FileSystemEventType::Delete, "", oldFilePath } ); } - Node* childNode = parent->mChildren[index.row()]; - { - Lock l( mResourceLock ); - childNode->rename( file ); - parent->mChildren.erase( parent->mChildren.begin() + index.row() ); - } - - size_t pos = getFileIndex( node->getParent(), file ); + Node* targetParent = getNodeFromPath( + file.isDirectory() ? FileSystem::removeLastFolderFromPath( file.getDirectoryPath() ) + : file.getDirectoryPath(), + true, false ); + // Keep unopened branches lazy. The node only needs to disappear from its old, + // materialized parent; a later traversal of the destination will discover it. + if ( !targetParent ) + return handleFileEventLocked( { FileSystemEventType::Delete, "", oldFilePath } ); // Don't add the file if already exists (if moved an old file to another old // file) - if ( pos == INDEX_ALREADY_EXISTS ) { - eeDelete( childNode ); - return false; - } + Node* targetChild = targetParent->findChildName( file.getFileName(), *this ); + if ( targetChild && targetChild != node ) + return handleFileEventLocked( { FileSystemEventType::Delete, "", oldFilePath } ); - std::map> keptSelections; - std::map> prevSelections; - std::map> prevSelectionsModelIndex; + UnorderedMap> selections; forEachView( [&]( UIAbstractView* view ) { view->getSelection().forEachIndex( [&]( const ModelIndex& selectedIndex ) { - Node* curNode = static_cast( selectedIndex.internalData() ); - if ( curNode->mParent == parent ) { - prevSelectionsModelIndex[view].emplace_back( selectedIndex ); - prevSelections[view].emplace_back( - ( curNode->getName() == event.oldFilename ) ? event.filename - : curNode->getName() ); - } else { - keptSelections[view].emplace_back( selectedIndex ); - } + selections[view].emplace_back( selectedIndex ); } ); } ); - beginMoveRows( index.parent(), index.row(), index.row(), index.parent(), pos ); + Node* childNode = sourceParent->mChildren[index.row()]; + size_t pos = getFileIndex( targetParent, file, childNode ); + eeASSERT( pos != INDEX_ALREADY_EXISTS ); + beginMoveRows( sourceParentIndex, index.row(), index.row(), + targetParent->index( *this, 0 ), pos ); { Lock l( mResourceLock ); - if ( pos >= parent->mChildren.size() ) { - parent->mChildren.emplace_back( childNode ); + sourceParent->mChildren.erase( sourceParent->mChildren.begin() + index.row() ); + childNode->rename( file ); + childNode->mParent = targetParent; + } + + { + Lock l( mResourceLock ); + if ( pos >= targetParent->mChildren.size() ) { + targetParent->mChildren.emplace_back( childNode ); } else { - parent->mChildren.insert( parent->mChildren.begin() + pos, childNode ); + targetParent->mChildren.insert( targetParent->mChildren.begin() + pos, + childNode ); } } endMoveRows(); forEachView( [&]( UIAbstractView* view ) { - std::vector names = prevSelections[view]; - std::vector newIndexes = keptSelections[view]; - int i = 0; - for ( const auto& name : names ) { - Int64 row = -1; - { - Lock l( mResourceLock ); - row = parent->findChildRowFromName( name, *this ); - } - if ( row >= 0 ) { - newIndexes.emplace_back( - this->index( row, prevSelectionsModelIndex[view][i].column(), - prevSelectionsModelIndex[view][i].parent() ) ); - } - ++i; + std::vector newIndexes; + newIndexes.reserve( selections[view].size() ); + for ( const ModelIndex& selectedIndex : selections[view] ) { + Node* selectedNode = static_cast( selectedIndex.internalData() ); + ModelIndex newIndex = selectedNode->index( *this, selectedIndex.column() ); + if ( newIndex.isValid() ) + newIndexes.emplace_back( std::move( newIndex ) ); } view->getSelection().set( newIndexes, false ); } ); diff --git a/src/eepp/ui/models/model.cpp b/src/eepp/ui/models/model.cpp index d7d413347..299ca6232 100644 --- a/src/eepp/ui/models/model.cpp +++ b/src/eepp/ui/models/model.cpp @@ -80,8 +80,10 @@ void Model::beginMoveRows( ModelIndex const& sourceParent, int first, int last, eeASSERT( first >= 0 ); eeASSERT( first <= last ); eeASSERT( targetIndex >= 0 ); - mOperationStack.push( { OperationType::Move, Direction::Row, sourceParent, first, last, - targetParent, targetIndex } ); + Operation operation{ OperationType::Move, Direction::Row, sourceParent, first, last, + targetParent, targetIndex }; + saveMovedIndices( operation ); + mOperationStack.push( std::move( operation ) ); } void Model::beginMoveColumns( ModelIndex const& sourceParent, int first, int last, @@ -89,12 +91,55 @@ void Model::beginMoveColumns( ModelIndex const& sourceParent, int first, int las eeASSERT( first >= 0 ); eeASSERT( first <= last ); eeASSERT( targetIndex >= 0 ); - mOperationStack.push( { OperationType::Move, Direction::Column, sourceParent, first, last, - targetParent, targetIndex } ); + Operation operation{ OperationType::Move, Direction::Column, sourceParent, first, last, + targetParent, targetIndex }; + saveMovedIndices( operation ); + mOperationStack.push( std::move( operation ) ); +} + +void Model::saveMovedIndices( Operation& operation ) { + const bool isRow = operation.direction == Direction::Row; + const bool moveWithin = operation.sourceParent == operation.targetParent; + const bool movingDown = operation.target > operation.first; + const int count = operation.last - operation.first + 1; + const int workAreaStart = std::min( operation.first, operation.target ); + const int workAreaEnd = std::max( operation.last + 1, operation.target + count ); + + operation.persistentMoves.reserve( mPersistentHandles.size() ); + for ( const auto& entry : mPersistentHandles ) { + const ModelIndex& index = entry.first; + const int dimension = isRow ? index.row() : index.column(); + const ModelIndex parent = index.parent(); + + if ( parent == operation.sourceParent && dimension >= operation.first && + dimension <= operation.last ) { + operation.persistentMoves.push_back( + { index, operation.targetParent, operation.target + dimension - operation.first } ); + } else if ( moveWithin && parent == operation.sourceParent ) { + if ( movingDown && dimension > operation.last && dimension < workAreaEnd ) { + operation.persistentMoves.push_back( + { index, operation.sourceParent, + workAreaStart + dimension - operation.last - 1 } ); + } else if ( !movingDown && dimension >= workAreaStart && dimension < operation.first ) { + operation.persistentMoves.push_back( + { index, operation.sourceParent, dimension + count } ); + } + } else if ( !moveWithin && parent == operation.sourceParent && + dimension > operation.last ) { + operation.persistentMoves.push_back( + { index, operation.sourceParent, dimension - count } ); + } else if ( !moveWithin && parent == operation.targetParent && + dimension >= operation.target ) { + operation.persistentMoves.push_back( + { index, operation.targetParent, dimension + count } ); + } + } } bool Model::beginDeleteRows( ModelIndex const& parent, int first, int last ) { if ( first >= 0 && first <= last && (size_t)last < rowCount( parent ) ) { + for ( int row = first; row <= last; ++row ) + notifyIndexDeleted( index( row, 0, parent ).internalData() ); saveDeletedIndices( parent, first, last ); mOperationStack.push( { OperationType::Delete, Direction::Row, parent, first, last } ); return true; @@ -102,6 +147,11 @@ bool Model::beginDeleteRows( ModelIndex const& parent, int first, int last ) { return false; } +void Model::notifyIndexDeleted( const void* internalData ) const { + forEachView( + [internalData]( UIAbstractView* view ) { view->onModelIndexDeleted( internalData ); } ); +} + bool Model::beginDeleteColumns( ModelIndex const& parent, int first, int last ) { if ( first >= 0 && first <= last && (size_t)last < columnCount( parent ) ) { saveDeletedIndices( parent, first, last ); @@ -232,75 +282,70 @@ void Model::endDeleteColumns() { void Model::handleInsert( Operation const& operation ) { bool isRow = operation.direction == Direction::Row; - std::vector toShift; + std::vector> indexChanges; + int offset = operation.last - operation.first + 1; for ( auto& entry : mPersistentHandles ) { if ( entry.first.parent() == operation.sourceParent ) { - if ( isRow && entry.first.row() >= operation.first ) { - toShift.emplace_back( &entry.first ); - } else if ( !isRow && entry.first.column() >= operation.first ) { - toShift.emplace_back( &entry.first ); - } + bool shifts = isRow ? entry.first.row() >= operation.first + : entry.first.column() >= operation.first; + if ( !shifts ) + continue; + int newRow = isRow ? entry.first.row() + offset : entry.first.row(); + int newColumn = isRow ? entry.first.column() : entry.first.column() + offset; + indexChanges.emplace_back( entry.first, + createIndex( newRow, newColumn, entry.first.internalData(), + entry.first.internalId() ) ); } } - int offset = operation.last - operation.first + 1; - - for ( auto currentIndex : toShift ) { - int newRow = isRow ? currentIndex->row() + offset : currentIndex->row(); - int newColumn = isRow ? currentIndex->column() : currentIndex->column() + offset; - auto newIndex = createIndex( newRow, newColumn, currentIndex->internalData() ); - - auto it = mPersistentHandles.find( *currentIndex ); - auto handle = std::move( it->second ); - - handle->mIndex = newIndex; - - mPersistentHandles.erase( it ); - mPersistentHandles[std::move( newIndex )] = std::move( handle ); - } + applyPersistentIndexChanges( indexChanges ); } void Model::handleDelete( Operation const& operation ) { bool isRow = operation.direction == Direction::Row; std::vector deletedIndices = mDeletedIndicesStack.top(); mDeletedIndicesStack.pop(); - std::vector toShift; + std::vector> indexChanges; // Get rid of all persistent handles which have been marked for death for ( auto& deletedIndex : deletedIndices ) { mPersistentHandles.erase( deletedIndex ); } + int offset = operation.last - operation.first + 1; for ( auto& entry : mPersistentHandles ) { if ( entry.first.parent() == operation.sourceParent ) { - if ( isRow ) { - if ( entry.first.row() > operation.last ) { - toShift.emplace_back( &entry.first ); - } - } else { - if ( entry.first.column() > operation.last ) { - toShift.emplace_back( &entry.first ); - } - } + bool shifts = + isRow ? entry.first.row() > operation.last : entry.first.column() > operation.last; + if ( !shifts ) + continue; + int newRow = isRow ? entry.first.row() - offset : entry.first.row(); + int newColumn = isRow ? entry.first.column() : entry.first.column() - offset; + indexChanges.emplace_back( entry.first, + createIndex( newRow, newColumn, entry.first.internalData(), + entry.first.internalId() ) ); } } - int offset = operation.last - operation.first + 1; - - for ( auto currentIndex : toShift ) { - int newRow = isRow ? currentIndex->row() - offset : currentIndex->row(); - int newColumn = isRow ? currentIndex->column() : currentIndex->column() - offset; - auto newIndex = createIndex( newRow, newColumn, currentIndex->internalData() ); - - auto it = mPersistentHandles.find( *currentIndex ); - auto handle = std::move( it->second ); - - handle->mIndex = newIndex; + applyPersistentIndexChanges( indexChanges ); +} +void Model::applyPersistentIndexChanges( + const std::vector>& indexChanges ) { + std::vector>> changedHandles; + changedHandles.reserve( indexChanges.size() ); + for ( const auto& change : indexChanges ) { + auto it = mPersistentHandles.find( change.first ); + if ( it == mPersistentHandles.end() ) + continue; + it->second->mIndex = change.second; + changedHandles.emplace_back( change.second, std::move( it->second ) ); mPersistentHandles.erase( it ); - mPersistentHandles[std::move( newIndex )] = std::move( handle ); } + for ( auto& changedHandle : changedHandles ) + mPersistentHandles.emplace( std::move( changedHandle.first ), + std::move( changedHandle.second ) ); } Variant Model::stylizeModel( const ModelIndex& index, const void* data ) const { @@ -336,101 +381,18 @@ void Model::unsubscribeModelStyler( Uint32 id ) { void Model::handleMove( Operation const& operation ) { bool isRow = operation.direction == Direction::Row; bool moveWithin = operation.sourceParent == operation.targetParent; - bool movingDown = operation.target > operation.first; if ( moveWithin && operation.first == operation.target ) return; - if ( isRow ) { - eeASSERT( operation.target <= (int)rowCount( operation.targetParent ) ); - eeASSERT( operation.last < (int)rowCount( operation.sourceParent ) ); - } else { - eeASSERT( operation.target <= (int)columnCount( operation.targetParent ) ); - eeASSERT( operation.last < (int)columnCount( operation.sourceParent ) ); - } - - // NOTE: to_shift_down is used as a generic "to shift" when move_within is true. - std::vector toMove; // Items to be moved between the source and target - std::vector toShiftDown; // Items to be shifted down after a move-to - std::vector toShiftUp; // Items to be shifted up after a move-from - - int count = operation.last - operation.first + 1; - // [start, end) - int workAreaStart = std::min( operation.first, operation.target ); - int work_area_end = std::max( operation.last + 1, operation.target + count ); - - for ( auto& entry : mPersistentHandles ) { - int dimension = isRow ? entry.first.row() : entry.first.column(); - - if ( moveWithin ) { - if ( entry.first.parent() == operation.sourceParent ) { - if ( dimension >= operation.first && dimension <= operation.last ) { - toMove.emplace_back( &entry.first ); - } else if ( movingDown && dimension > operation.last && - dimension < work_area_end ) { - toShiftDown.emplace_back( &entry.first ); - } else if ( !movingDown && dimension >= workAreaStart && - dimension < operation.first ) { - toShiftDown.emplace_back( &entry.first ); - } - } - } else { - if ( entry.first.parent() == operation.sourceParent ) { - if ( dimension >= operation.first && dimension <= operation.last ) { - toMove.emplace_back( &entry.first ); - } else if ( dimension > operation.last ) { - toShiftUp.emplace_back( &entry.first ); - } - } else if ( entry.first.parent() == operation.targetParent ) { - if ( dimension >= operation.target ) { - toShiftDown.emplace_back( &entry.first ); - } - } - } - } - - auto replaceHandle = [&]( ModelIndex const& currentIndex, int newDimension, bool relative ) { - int newRow = isRow ? ( relative ? currentIndex.row() + newDimension : newDimension ) - : currentIndex.row(); - int newColumn = !isRow ? ( relative ? currentIndex.column() + newDimension : newDimension ) - : currentIndex.column(); - auto newIndex = index( newRow, newColumn, operation.targetParent ); - - auto it = mPersistentHandles.find( currentIndex ); - auto handle = std::move( it->second ); - - handle->mIndex = newIndex; - - mPersistentHandles.erase( it ); - mPersistentHandles[std::move( newIndex )] = std::move( handle ); - }; - - for ( auto currentIndex : toMove ) { - int dimension = isRow ? currentIndex->row() : currentIndex->column(); - int targetOffset = dimension - operation.first; - int newDimension = operation.target + targetOffset; - - replaceHandle( *currentIndex, newDimension, false ); - } - - if ( moveWithin ) { - for ( auto currentIndex : toShiftDown ) { - int dimension = isRow ? currentIndex->row() : currentIndex->column(); - int targetOffset = - movingDown ? dimension - ( operation.last + 1 ) : dimension - workAreaStart + count; - int newDimension = workAreaStart + targetOffset; - - replaceHandle( *currentIndex, newDimension, false ); - } - } else { - for ( auto currentIndex : toShiftDown ) { - replaceHandle( *currentIndex, count, true ); - } - - for ( auto currentIndex : toShiftUp ) { - replaceHandle( *currentIndex, count, true ); - } + std::vector> indexChanges; + indexChanges.reserve( operation.persistentMoves.size() ); + for ( const auto& move : operation.persistentMoves ) { + int newRow = isRow ? move.targetDimension : move.index.row(); + int newColumn = isRow ? move.index.column() : move.targetDimension; + indexChanges.emplace_back( move.index, index( newRow, newColumn, move.targetParent ) ); } + applyPersistentIndexChanges( indexChanges ); } }}} // namespace EE::UI::Models diff --git a/src/eepp/ui/uitreeview.cpp b/src/eepp/ui/uitreeview.cpp index d2fa04ef2..a022ddae9 100644 --- a/src/eepp/ui/uitreeview.cpp +++ b/src/eepp/ui/uitreeview.cpp @@ -45,6 +45,10 @@ UITreeView::MetadataForIndex& UITreeView::getIndexMetadata( const ModelIndex& in return mViewMetadata[index.internalData()]; } +void UITreeView::onModelIndexDeleted( const void* internalData ) { + mViewMetadata.erase( const_cast( internalData ) ); +} + UITreeView::IterationDecision UITreeView::traverseIndex( TraverseTreeVars& v, const ModelIndex& index ) const { if ( index.isValid() ) { diff --git a/src/tests/unit_tests/fileinfo_tests.cpp b/src/tests/unit_tests/fileinfo_tests.cpp new file mode 100644 index 000000000..e442b085e --- /dev/null +++ b/src/tests/unit_tests/fileinfo_tests.cpp @@ -0,0 +1,74 @@ +#include "utest.hpp" + +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; + +namespace { + +class FileInfoIdentity : public FileInfo { + public: + void setIdentity( Uint64 device, Uint64 inode ) { + mDevice = device; + mInode = inode; + } +}; + +struct TempDirectory { + TempDirectory() { + path = std::filesystem::temp_directory_path() / + ( "eepp-fileinfo-" + + std::to_string( std::chrono::steady_clock::now().time_since_epoch().count() ) ); + std::filesystem::create_directories( path ); + } + + ~TempDirectory() { std::filesystem::remove_all( path ); } + + std::filesystem::path path; +}; + +} // namespace + +UTEST( FileInfo, sameInodeUsesDeviceAndRejectsInvalidIdentity ) { + FileInfoIdentity first; + FileInfoIdentity same; + FileInfoIdentity otherDevice; + FileInfoIdentity invalid; + + first.setIdentity( 1, 42 ); + same.setIdentity( 1, 42 ); + otherDevice.setIdentity( 2, 42 ); + invalid.setIdentity( 1, 0 ); + + EXPECT_TRUE( FileInfo::inodeSupported() ); + EXPECT_TRUE( first.sameInode( same ) ); + EXPECT_FALSE( first.sameInode( otherDevice ) ); + EXPECT_FALSE( invalid.sameInode( invalid ) ); +} + +UTEST( FileInfo, reportsHardLinkIdentityAndLinkCount ) { + TempDirectory temp; + const std::filesystem::path original = temp.path / "original.txt"; + const std::filesystem::path hardLink = temp.path / "hard-link.txt"; + + ASSERT_TRUE( FileSystem::fileWrite( original.string(), "contents" ) ); + std::error_code error; + std::filesystem::create_hard_link( original, hardLink, error ); + if ( error ) { + const std::string message = "hard links are unavailable: " + error.message(); + UTEST_SKIP( message.c_str() ); + } + + FileInfo originalInfo( original.string() ); + FileInfo hardLinkInfo( hardLink.string() ); + + EXPECT_TRUE( originalInfo.sameInode( hardLinkInfo ) ); + EXPECT_EQ( originalInfo.getDevice(), hardLinkInfo.getDevice() ); + EXPECT_EQ( originalInfo.getInode(), hardLinkInfo.getInode() ); + EXPECT_TRUE( originalInfo.getLinkCount() >= 2 ); + EXPECT_TRUE( hardLinkInfo.getLinkCount() >= 2 ); +} diff --git a/src/tests/unit_tests/modeloperations_tests.cpp b/src/tests/unit_tests/modeloperations_tests.cpp new file mode 100644 index 000000000..da156658b --- /dev/null +++ b/src/tests/unit_tests/modeloperations_tests.cpp @@ -0,0 +1,272 @@ +#include "utest.hpp" + +#include +#include +#include +#include + +using namespace EE::UI::Models; + +namespace { + +class MoveTestModel : public Model { + public: + struct Node { + std::string name; + Node* parent{ nullptr }; + std::vector> children; + }; + + MoveTestModel() { + a = append( root, "a" ); + b = append( root, "b" ); + a0 = append( *a, "a0" ); + a1 = append( *a, "a1" ); + a2 = append( *a, "a2" ); + b0 = append( *b, "b0" ); + b1 = append( *b, "b1" ); + } + + size_t rowCount( const ModelIndex& parent = {} ) const { + return node( parent ).children.size(); + } + + size_t columnCount( const ModelIndex& = {} ) const { return 1; } + + Variant data( const ModelIndex& index, ModelRole = ModelRole::Display ) const { + return static_cast( index.internalData() )->name.c_str(); + } + + ModelIndex parentIndex( const ModelIndex& index ) const { + Node* parent = static_cast( index.internalData() )->parent; + if ( !parent || parent == &root ) + return {}; + return indexFor( parent ); + } + + ModelIndex index( int row, int column = 0, const ModelIndex& parent = {} ) const { + const auto& children = node( parent ).children; + if ( row < 0 || static_cast( row ) >= children.size() ) + return {}; + return createIndex( row, column, children[row].get() ); + } + + ModelIndex indexFor( Node* node ) const { + for ( size_t row = 0; row < node->parent->children.size(); ++row ) + if ( node->parent->children[row].get() == node ) + return createIndex( row, 0, node ); + return {}; + } + + void moveA1ToB() { + beginMoveRows( indexFor( a ), 1, 1, indexFor( b ), 1 ); + a1->parent = b; + auto moved = std::move( a->children[1] ); + a->children.erase( a->children.begin() + 1 ); + b->children.insert( b->children.begin() + 1, std::move( moved ) ); + endMoveRows(); + } + + Node root{ "root" }; + Node* a{ nullptr }; + Node* b{ nullptr }; + Node* a0{ nullptr }; + Node* a1{ nullptr }; + Node* a2{ nullptr }; + Node* b0{ nullptr }; + Node* b1{ nullptr }; + + private: + static Node* append( Node& parent, std::string name ) { + parent.children.emplace_back( + std::make_unique( Node{ std::move( name ), &parent } ) ); + return parent.children.back().get(); + } + + const Node& node( const ModelIndex& index ) const { + return index.isValid() ? *static_cast( index.internalData() ) : root; + } +}; + +class PersistentRowsModel : public Model { + public: + PersistentRowsModel( bool useInternalIds ) : mUseInternalIds( useInternalIds ) { + mRows = { 10, 20, 30, 40 }; + } + + size_t rowCount( const ModelIndex& = {} ) const { return mRows.size(); } + + size_t columnCount( const ModelIndex& = {} ) const { return 1; } + + Variant data( const ModelIndex& index, ModelRole = ModelRole::Display ) const { + return mRows[index.row()]; + } + + ModelIndex index( int row, int column = 0, const ModelIndex& = {} ) const { + if ( row < 0 || static_cast( row ) >= mRows.size() ) + return {}; + return createIndex( row, column, nullptr, mUseInternalIds ? mRows[row] : 0 ); + } + + void insertAt( int row, int value ) { + beginInsertRows( {}, row, row ); + mRows.insert( mRows.begin() + row, value ); + endInsertRows(); + } + + void deleteAt( int row ) { + if ( beginDeleteRows( {}, row, row ) ) { + mRows.erase( mRows.begin() + row ); + endDeleteRows(); + } + } + + private: + std::vector mRows; + bool mUseInternalIds; +}; + +struct TempTree { + TempTree() { + static unsigned long long id = 0; + path = std::filesystem::temp_directory_path() / + ( "eepp-filesystem-model-move-" + std::to_string( ++id ) ); + std::filesystem::remove_all( path ); + std::filesystem::create_directories( path ); + } + + ~TempTree() { std::filesystem::remove_all( path ); } + + std::filesystem::path path; +}; + +} // namespace + +UTEST( ModelMove, crossParentPersistentIndexesFollowNodesAndShiftSiblings ) { + MoveTestModel model; + PersistentModelIndex moved( model.indexFor( model.a1 ) ); + PersistentModelIndex sourceSibling( model.indexFor( model.a2 ) ); + PersistentModelIndex targetSibling( model.indexFor( model.b1 ) ); + + model.moveA1ToB(); + + ASSERT_EQ( moved.row(), 1 ); + ASSERT_EQ( static_cast( moved ).internalData(), model.a1 ); + ASSERT_EQ( static_cast( moved.parent() ).internalData(), model.b ); + ASSERT_EQ( sourceSibling.row(), 1 ); + ASSERT_EQ( static_cast( sourceSibling ).internalData(), model.a2 ); + ASSERT_EQ( static_cast( sourceSibling.parent() ).internalData(), model.a ); + ASSERT_EQ( targetSibling.row(), 2 ); + ASSERT_EQ( static_cast( targetSibling ).internalData(), model.b1 ); + ASSERT_EQ( static_cast( targetSibling.parent() ).internalData(), model.b ); +} + +UTEST( ModelInsert, persistentIndexesShiftWithoutIdentityCollisions ) { + PersistentRowsModel model( false ); + PersistentModelIndex first( model.index( 0 ) ); + PersistentModelIndex second( model.index( 1 ) ); + PersistentModelIndex third( model.index( 2 ) ); + + model.insertAt( 0, 5 ); + + ASSERT_EQ( first.row(), 1 ); + ASSERT_EQ( second.row(), 2 ); + ASSERT_EQ( third.row(), 3 ); + ASSERT_EQ( first.data().asInt(), 10 ); + ASSERT_EQ( second.data().asInt(), 20 ); + ASSERT_EQ( third.data().asInt(), 30 ); +} + +UTEST( ModelInsert, persistentIndexesPreserveInternalIds ) { + PersistentRowsModel model( true ); + PersistentModelIndex tracked( model.index( 2 ) ); + + model.insertAt( 1, 15 ); + + ModelIndex index = tracked; + ASSERT_EQ( tracked.row(), 3 ); + ASSERT_EQ( index.internalId(), 30 ); +} + +UTEST( ModelDelete, persistentIndexesExpireOrShiftWithoutIdentityCollisions ) { + PersistentRowsModel model( false ); + PersistentModelIndex deleted( model.index( 1 ) ); + PersistentModelIndex third( model.index( 2 ) ); + PersistentModelIndex fourth( model.index( 3 ) ); + + model.deleteAt( 1 ); + + ASSERT_FALSE( deleted.isValid() ); + ASSERT_EQ( third.row(), 1 ); + ASSERT_EQ( fourth.row(), 2 ); + ASSERT_EQ( third.data().asInt(), 30 ); + ASSERT_EQ( fourth.data().asInt(), 40 ); +} + +UTEST( ModelDelete, persistentIndexesPreserveInternalIds ) { + PersistentRowsModel model( true ); + PersistentModelIndex tracked( model.index( 3 ) ); + + model.deleteAt( 1 ); + + ModelIndex index = tracked; + ASSERT_EQ( tracked.row(), 2 ); + ASSERT_EQ( index.internalId(), 40 ); +} + +UTEST( FileSystemModelMove, preservesNodeAndDescendantPathsAcrossDirectories ) { + TempTree tree; + auto source = tree.path / "source"; + auto target = tree.path / "target"; + std::filesystem::create_directories( source / "folder" ); + std::filesystem::create_directories( target ); + std::FILE* file = std::fopen( ( source / "folder" / "file.txt" ).string().c_str(), "wb" ); + ASSERT_TRUE( file != nullptr ); + std::fclose( file ); + + auto model = FileSystemModel::New( tree.path.string() ); + auto* folderNode = model->getNodeFromPath( ( source / "folder" ).string(), true ); + auto* fileNode = model->getNodeFromPath( ( source / "folder" / "file.txt" ).string() ); + ASSERT_TRUE( folderNode != nullptr ); + ASSERT_TRUE( fileNode != nullptr ); + + std::filesystem::rename( source / "folder", target / "renamed" ); + ASSERT_EQ( model->getNodeFromPath( ( source / "folder" ).string(), false, false ), folderNode ); + ASSERT_TRUE( model->handleFileEvent( { FileSystemEventType::Moved, + target.string() + EE::System::FileSystem::getOSSlash(), + "renamed", ( source / "folder" ).string() } ) ); + + std::string expectedFolderPath( ( target / "renamed" ).string() ); + EE::System::FileSystem::dirAddSlashAtEnd( expectedFolderPath ); + ASSERT_STDSTREQ( folderNode->fullPath(), expectedFolderPath ); + ASSERT_EQ( model->getNodeFromPath( ( target / "renamed" ).string(), true, false ), folderNode ); + ASSERT_EQ( model->getNodeFromPath( ( target / "renamed" / "file.txt" ).string(), false, false ), + fileNode ); + ASSERT_STDSTREQ( fileNode->fullPath(), ( target / "renamed" / "file.txt" ).string() ); +} + +UTEST( FileSystemModelMove, keepsUnopenedDestinationBranchLazy ) { + TempTree tree; + auto source = tree.path / "source"; + auto destination = tree.path / "outer" / "destination"; + std::filesystem::create_directories( source ); + std::filesystem::create_directories( destination ); + std::FILE* file = std::fopen( ( source / "file.txt" ).string().c_str(), "wb" ); + ASSERT_TRUE( file != nullptr ); + std::fclose( file ); + + auto model = FileSystemModel::New( tree.path.string() ); + ASSERT_TRUE( model->getNodeFromPath( ( source / "file.txt" ).string() ) != nullptr ); + ASSERT_TRUE( model->getNodeFromPath( destination.string(), true, false ) == nullptr ); + + std::filesystem::rename( source / "file.txt", destination / "file.txt" ); + ASSERT_TRUE( model->handleFileEvent( + { FileSystemEventType::Moved, destination.string() + EE::System::FileSystem::getOSSlash(), + "file.txt", ( source / "file.txt" ).string() } ) ); + + ASSERT_TRUE( model->getNodeFromPath( ( source / "file.txt" ).string(), false, false ) == + nullptr ); + ASSERT_TRUE( model->getNodeFromPath( destination.string(), true, false ) == nullptr ); + ASSERT_TRUE( model->getNodeFromPath( ( destination / "file.txt" ).string(), false, true ) != + nullptr ); +} diff --git a/src/thirdparty/SOIL2 b/src/thirdparty/SOIL2 index af13ed898..dc92c9ec6 160000 --- a/src/thirdparty/SOIL2 +++ b/src/thirdparty/SOIL2 @@ -1 +1 @@ -Subproject commit af13ed898a05fb1dbd4c16306e630f9caf27a6f6 +Subproject commit dc92c9ec6d83cda2a059e85ebb24d51d0632d7b2 diff --git a/src/thirdparty/efsw b/src/thirdparty/efsw index 3535c3a38..cb189cd3c 160000 --- a/src/thirdparty/efsw +++ b/src/thirdparty/efsw @@ -1 +1 @@ -Subproject commit 3535c3a382c505d8b9dae4b59061ac3ff94470fb +Subproject commit cb189cd3cecfc882b2fd7779319d9c99ee67e9dd diff --git a/src/tools/ecode/ecode.cpp b/src/tools/ecode/ecode.cpp index 470737eb8..de44db4fc 100644 --- a/src/tools/ecode/ecode.cpp +++ b/src/tools/ecode/ecode.cpp @@ -3572,7 +3572,8 @@ void App::loadDirTree( const std::string& path ) { mFolderWatches.insert( { dirTree.getPath(), 0 } ); } mFolderWatches[dirTree.getPath()] = - mFileWatcher->addWatch( dirTree.getPath(), mFileSystemListener, true ); + mFileWatcher->addWatch( dirTree.getPath(), mFileSystemListener, true, + { { efsw::Options::ReportCrossDirectoryMoves, 1 } } ); } mFileSystemListener->setDirTree( mDirTree ); }, diff --git a/src/tools/ecode/filesystemlistener.cpp b/src/tools/ecode/filesystemlistener.cpp index de5a5339f..326f2029d 100644 --- a/src/tools/ecode/filesystemlistener.cpp +++ b/src/tools/ecode/filesystemlistener.cpp @@ -62,14 +62,12 @@ void FileSystemListener::handleFileAction( efsw::WatchID, const std::string& dir if ( file.isLink() ) file = FileInfo( file.linksTo() ); - if ( isFileOpen( oldFile ) ) - notifyMove( oldFile, file ); + notifyMove( oldFile, file ); if ( oldFile.isLink() ) { oldFile = FileInfo( oldFile.linksTo() ); - if ( isFileOpen( oldFile ) ) - notifyMove( oldFile, file ); + notifyMove( oldFile, file ); } } else if ( action == efsw::Actions::Delete ) { notifyDelete( file ); @@ -83,8 +81,7 @@ void FileSystemListener::handleFileAction( efsw::WatchID, const std::string& dir Lock l( mCbsMutex ); if ( !mCbs.empty() ) { - auto cbs = mCbs; - for ( const auto& cb : cbs ) + for ( const auto& cb : mCbs ) cb.second( event, file ); } @@ -98,9 +95,8 @@ void FileSystemListener::handleFileAction( efsw::WatchID, const std::string& dir Lock l( mCbsMutex ); if ( !mCbs.empty() ) { - auto cbs = mCbs; FileEvent event( (FileSystemEventType)action, dir, filename, oldFilename ); - for ( const auto& cb : cbs ) + for ( const auto& cb : mCbs ) cb.second( event, file ); } } @@ -162,9 +158,21 @@ void FileSystemListener::notifyChange( const FileInfo& file ) { } void FileSystemListener::notifyMove( const FileInfo& oldFile, const FileInfo& newFile ) { + std::string oldPath( oldFile.getFilepath() ); + std::string newPath( newFile.getFilepath() ); + const bool directoryMoved = newFile.isDirectory(); + if ( directoryMoved ) { + FileSystem::dirAddSlashAtEnd( oldPath ); + FileSystem::dirAddSlashAtEnd( newPath ); + } + mSplitter->forEachDoc( [&]( TextDocument& doc ) { - if ( oldFile.getFilepath() == doc.getFileInfo().getFilepath() ) - doc.notifyDocumentMoved( newFile.getFilepath() ); + const std::string& documentPath = doc.getFileInfo().getFilepath(); + if ( oldPath == documentPath ) { + doc.notifyDocumentMoved( newPath ); + } else if ( directoryMoved && String::startsWith( documentPath, oldPath ) ) { + doc.notifyDocumentMoved( newPath + documentPath.substr( oldPath.size() ) ); + } } ); }