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
This commit is contained in:
Martín Lucas Golini
2026-08-17 22:42:52 -03:00
parent ffabb19d60
commit 1534253e54
17 changed files with 626 additions and 215 deletions
+67 -7
View File
@@ -15,6 +15,10 @@
#include <limits.h>
#include <stdlib.h>
#if EE_PLATFORM == EE_PLATFORM_WIN
#include <windows.h>
#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<int>( 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<size_t>( 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<Uint64>( 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 {
+9 -5
View File
@@ -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
+56 -51
View File
@@ -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<FileInfo> 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<UIAbstractView*, std::vector<ModelIndex>> keptSelections;
std::map<UIAbstractView*, std::vector<std::string>> prevSelections;
std::map<UIAbstractView*, std::vector<ModelIndex>> prevSelectionsModelIndex;
UnorderedMap<UIAbstractView*, std::vector<ModelIndex>> selections;
forEachView( [&]( UIAbstractView* view ) {
view->getSelection().forEachIndex( [&]( const ModelIndex& selectedIndex ) {
Node* curNode = static_cast<Node*>( 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<std::string> names = prevSelections[view];
std::vector<ModelIndex> 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<ModelIndex> newIndexes;
newIndexes.reserve( selections[view].size() );
for ( const ModelIndex& selectedIndex : selections[view] ) {
Node* selectedNode = static_cast<Node*>( selectedIndex.internalData() );
ModelIndex newIndex = selectedNode->index( *this, selectedIndex.column() );
if ( newIndex.isValid() )
newIndexes.emplace_back( std::move( newIndex ) );
}
view->getSelection().set( newIndexes, false );
} );
+99 -137
View File
@@ -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<true>( 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<false>( parent, first, last );
@@ -232,75 +282,70 @@ void Model::endDeleteColumns() {
void Model::handleInsert( Operation const& operation ) {
bool isRow = operation.direction == Direction::Row;
std::vector<const ModelIndex*> toShift;
std::vector<std::pair<ModelIndex, ModelIndex>> 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<ModelIndex> deletedIndices = mDeletedIndicesStack.top();
mDeletedIndicesStack.pop();
std::vector<const ModelIndex*> toShift;
std::vector<std::pair<ModelIndex, ModelIndex>> 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<std::pair<ModelIndex, ModelIndex>>& indexChanges ) {
std::vector<std::pair<ModelIndex, std::shared_ptr<PersistentHandle>>> 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<const ModelIndex*> toMove; // Items to be moved between the source and target
std::vector<const ModelIndex*> toShiftDown; // Items to be shifted down after a move-to
std::vector<const ModelIndex*> 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<std::pair<ModelIndex, ModelIndex>> 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
+4
View File
@@ -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<void*>( internalData ) );
}
UITreeView::IterationDecision UITreeView::traverseIndex( TraverseTreeVars& v,
const ModelIndex& index ) const {
if ( index.isValid() ) {
+74
View File
@@ -0,0 +1,74 @@
#include "utest.hpp"
#include <chrono>
#include <eepp/system/fileinfo.hpp>
#include <eepp/system/filesystem.hpp>
#include <filesystem>
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 );
}
@@ -0,0 +1,272 @@
#include "utest.hpp"
#include <eepp/system/filesystem.hpp>
#include <eepp/ui/models/filesystemmodel.hpp>
#include <eepp/ui/models/persistentmodelindex.hpp>
#include <filesystem>
using namespace EE::UI::Models;
namespace {
class MoveTestModel : public Model {
public:
struct Node {
std::string name;
Node* parent{ nullptr };
std::vector<std::unique_ptr<Node>> 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<Node*>( index.internalData() )->name.c_str();
}
ModelIndex parentIndex( const ModelIndex& index ) const {
Node* parent = static_cast<Node*>( 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<size_t>( 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>( Node{ std::move( name ), &parent } ) );
return parent.children.back().get();
}
const Node& node( const ModelIndex& index ) const {
return index.isValid() ? *static_cast<Node*>( 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<size_t>( 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<int> 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<ModelIndex>( moved ).internalData(), model.a1 );
ASSERT_EQ( static_cast<ModelIndex>( moved.parent() ).internalData(), model.b );
ASSERT_EQ( sourceSibling.row(), 1 );
ASSERT_EQ( static_cast<ModelIndex>( sourceSibling ).internalData(), model.a2 );
ASSERT_EQ( static_cast<ModelIndex>( sourceSibling.parent() ).internalData(), model.a );
ASSERT_EQ( targetSibling.row(), 2 );
ASSERT_EQ( static_cast<ModelIndex>( targetSibling ).internalData(), model.b1 );
ASSERT_EQ( static_cast<ModelIndex>( 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 );
}
+2 -1
View File
@@ -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 );
},
+18 -10
View File
@@ -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() ) );
}
} );
}