diff --git a/.agents/rules/build-project.md b/.agents/rules/build-project.md index 0f99709ce..6dd203f15 100644 --- a/.agents/rules/build-project.md +++ b/.agents/rules/build-project.md @@ -50,8 +50,11 @@ release Ninja build required for performance work. ## Benchmark Runtime +Native is the default runtime; normal native commands do not need `EEPP_RUNTIME=native`. Use that +assignment only to override an inherited non-native value or to test explicit runtime selection. + Run the release benchmark harness with the native runtime on a usable desktop: -`EEPP_RUNTIME=native bin/benchmarks/eepp-benchmarks`. +`bin/benchmarks/eepp-benchmarks`. The benchmarks that create windows explicitly request `WindowStyle::Hidden`; other benchmarks do not create windows. They can use the native renderer without showing desktop windows or taking focus. Do not switch this harness to headless merely to avoid interrupting the user. Verify the @@ -134,7 +137,7 @@ native runtime on a usable desktop. Use the native/Xvfb paths below for examples required check cannot run with the headless runtime. Verify the renderer before using an offscreen run to assess hardware rendering or performance. -When the headless driver is unavailable, use `EEPP_RUNTIME=native EEPP_WINDOW_HIDDEN=1` for ordinary +When the headless driver is unavailable, use `EEPP_WINDOW_HIDDEN=1` for ordinary application/example launches and inspector automation. This preserves native rendering while forcing primary and secondary eepp windows hidden, including explicit `show()`/restore requests; it avoids temporary source edits to add `WindowStyle::Hidden`. A usable native display is still diff --git a/.agents/rules/project-introduction.md b/.agents/rules/project-introduction.md index 12b5062c2..43132c874 100644 --- a/.agents/rules/project-introduction.md +++ b/.agents/rules/project-introduction.md @@ -38,10 +38,13 @@ database, launching applications) may use local platform APIs. See `docs/article ## AI Agent Runtime Checks +Native is the default runtime. Leave `EEPP_RUNTIME` unset for normal native runs; set it to +`native` only to override an inherited non-native value or to test explicit runtime selection. + AI agents should prefer `EEPP_RUNTIME=headless` when launching ordinary applications or examples and when automating their UI with the inspector. This avoids opening desktop windows or interrupting the human's keyboard input and focus. When headless rendering is unavailable (notably on macOS), -or native rendering needs verification, use `EEPP_RUNTIME=native EEPP_WINDOW_HIDDEN=1` instead of +or native rendering needs verification, use `EEPP_WINDOW_HIDDEN=1` instead of modifying application sources to add `WindowStyle::Hidden`. This also keeps secondary native windows hidden, even if the application calls `show()` or restores them. Coordinate interactive checks that require visible native windows with the user before driving them. diff --git a/.agents/rules/unit-tests.md b/.agents/rules/unit-tests.md index 473ddcd3e..622d280c4 100644 --- a/.agents/rules/unit-tests.md +++ b/.agents/rules/unit-tests.md @@ -5,10 +5,13 @@ This project relies on a comprehensive suite of unit tests to prevent regression ## Running Tests The test binary manages its own current working directory, so you can execute it from anywhere. +Native is the default runtime. Leave `EEPP_RUNTIME` unset for normal native runs; set it to +`native` only to override an inherited non-native value or to test explicit runtime selection. + The required default workflow is: 1. Build the release unit-test target (use ninja build). -2. On a usable desktop, run `EEPP_RUNTIME=native bin/unit_tests/eepp-unit_tests` directly, without +2. On a usable desktop, run `bin/unit_tests/eepp-unit_tests` directly, without Xvfb. The unit-test build defines `EE_UNIT_TESTS`, making `WindowStyle::Default` include `WindowStyle::Hidden`; native test windows remain hidden and do not take desktop focus. 3. If sandbox restrictions prevent access to the host display, retry the same native command with @@ -16,7 +19,7 @@ The required default workflow is: 4. When no usable display is available, use `EEPP_RUNTIME=headless bin/unit_tests/eepp-unit_tests`. Also use it when requested or when checking the headless runtime itself. 5. Use Xvfb only if there is no usable desktop display and the required check cannot run with the - headless runtime. Keep the explicit runtime selection in fallback commands below. + headless runtime. The fallback commands below use the default native runtime. The preference for headless application launches and interactive UI inspector checks does not change this harness workflow. Keep all native test windows hidden, including secondary dialog @@ -25,13 +28,13 @@ hosts; showing a UI widget inside a hidden host does not require showing the nat * **Use the release test binary during normal development:** When AddressSanitizer or other debug-only diagnostics are not required, build and run `bin/unit_tests/eepp-unit_tests`. The optimized release suite is substantially faster and is the required default for iterative testing. Use `bin/unit_tests/eepp-unit_tests-debug` only when investigating memory safety, assertions, or other behavior that specifically requires the debug configuration. * **Default Execution on a Graphical Desktop:** - `EEPP_RUNTIME=native bin/unit_tests/eepp-unit_tests` + `bin/unit_tests/eepp-unit_tests` Hidden windows preserve native rendering without appearing or taking desktop focus. Confirm that the renderer log names the real GPU rather than llvmpipe when validating rendering behavior or performance. * **Filtered Tests:** Use the same native runtime for focused runs on a usable desktop: - `EEPP_RUNTIME=native bin/unit_tests/eepp-unit_tests --filter="FontRendering.*Offset*"` + `bin/unit_tests/eepp-unit_tests --filter="FontRendering.*Offset*"` * **Headless Execution:** `EEPP_RUNTIME=headless bin/unit_tests/eepp-unit_tests` Use this when no usable desktop display is available, when requested, or for headless runtime @@ -39,14 +42,14 @@ hosts; showing a UI widget inside a hidden host does not require showing the nat do not necessarily represent hardware GPU performance. Add `--filter` for focused headless runs. * **Last Fallback When the Headless Runtime Cannot Support the Required Check:** Keep `projects/scripts/xvfb-run-eepp` as the fallback when no usable desktop display is available: - `EEPP_RUNTIME=native projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests` + `projects/scripts/xvfb-run-eepp bin/unit_tests/eepp-unit_tests` The wrapper provides a race-safe isolated display at `1280x1024x24` and injects `ASAN_OPTIONS=detect_leaks=0`. Xvfb can use llvmpipe, so do not use wrapper timings to evaluate hardware-rendered performance. * **Wrapper Fallback:** If `projects/scripts/xvfb-run-eepp` itself fails before launching the test binary, report that failure and use: - `EEPP_RUNTIME=native xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests` + `xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests` Do not use plain `xvfb-run` as the first headless attempt. * **Non-window Tests:** Tests known not to create windows can run without selecting a video driver: diff --git a/docs/articles/vfs.md b/docs/articles/vfs.md index 5ba680ddd..2a272bdc6 100644 --- a/docs/articles/vfs.md +++ b/docs/articles/vfs.md @@ -232,6 +232,24 @@ when a destination appears while the copy is running. The `Overwrite` policy req Creating a file with `Fail` or `Skip` requires `Rename`. Moving a symlink through the copy fallback is unsupported; native same-filesystem renames still move the link itself. +The four-argument `copy(source, destination, options, onConflict)` and `move()` overloads +optionally resolve each collision during the transfer, including files inside merged directories. +The callback receives an owned `TransferConflict` with source/destination URIs and metadata and +returns `VFSTask`. It runs on the transfer worker: dispatch +dialogs to the UI executor and resolve the task when the user answers. Cancellation stops waiting +for a decision. Completed entries are retained; the transfer continues from the conflicting entry. + +The actions are `Overwrite`, `OverwriteOlder`, `Skip`, `Rename`, and `Cancel`. `OverwriteOlder` +requires both modification times and skips unless the source is newer. `Rename` accepts a validated +new basename; an empty name generates an available sibling name while preserving the extension. +Files and directories cannot overwrite one another. With `MergeDirectories`, existing directories +are merged without prompting, while file collisions still invoke the resolver. + +Decisions apply to one entry. Applications implementing “Apply to All” should retain decisions for +the current operation, distinguishing directory merges from file overwrites. Explicit skips still +contribute to `TransferResult::filesSkipped`; callers can count their own skip decisions to +distinguish them from unsupported entries when reporting partial transfers. + `MergeDirectories` merges existing destination directories but reports `AlreadyExists` for files, including files that appear during the copy. It supports copying an archive root into an existing folder without overwriting files. The default `Fail` policy also rejects existing directories; diff --git a/src/modules/vfs/include/eepp/vfs/transfer.hpp b/src/modules/vfs/include/eepp/vfs/transfer.hpp index 0573d953e..afb7b4e2e 100644 --- a/src/modules/vfs/include/eepp/vfs/transfer.hpp +++ b/src/modules/vfs/include/eepp/vfs/transfer.hpp @@ -6,11 +6,35 @@ #include #include #include +#include +#include #include #include namespace EE { namespace VFS { +enum class ConflictAction : Uint8 { Cancel, Skip, Overwrite, OverwriteOlder, Rename }; + +struct EE_VFS_API TransferConflict { + URI source; + URI destination; + VFSFileInfo sourceInfo; + VFSFileInfo destinationInfo; +}; + +struct EE_VFS_API ConflictResolution { + /** Rename: a new basename, or empty to generate a unique name automatically. */ + std::string name; + ConflictAction action{ ConflictAction::Cancel }; +}; + +/** Per-entry conflict decision, called on a transfer worker. Dispatch UI work through an executor. + * The request is owned and can be moved into asynchronous work. Cancellation stops waiting for + * the task. OverwriteOlder skips if either modification time is unavailable. Type mismatches + * can only be skipped or renamed. Completed entries are never replayed. */ +using TransferConflictResolver = + std::function( TransferConflict, CancellationToken )>; + struct EE_VFS_API TransferProgress { URI currentSource; URI currentDestination; diff --git a/src/modules/vfs/include/eepp/vfs/virtualfilesystem.hpp b/src/modules/vfs/include/eepp/vfs/virtualfilesystem.hpp index fbae37624..52c667d48 100644 --- a/src/modules/vfs/include/eepp/vfs/virtualfilesystem.hpp +++ b/src/modules/vfs/include/eepp/vfs/virtualfilesystem.hpp @@ -151,10 +151,18 @@ class EE_VFS_API VirtualFileSystem { /** Copies a file or directory tree, across providers when necessary. */ Transfer copy( const URI& source, const URI& destination, const CopyOptions& options = {} ); + /** Copy with per-entry conflict decisions, including collisions inside directory trees. */ + Transfer copy( const URI& source, const URI& destination, const CopyOptions& options, + TransferConflictResolver onConflict ); + /** Moves a file or directory: native rename when possible, otherwise copy + remove of the * entries that were completely copied. */ Transfer move( const URI& source, const URI& destination, const CopyOptions& options = {} ); + /** Move with per-entry conflict decisions. Skipped source entries are retained. */ + Transfer move( const URI& source, const URI& destination, const CopyOptions& options, + TransferConflictResolver onConflict ); + /** Convenience for watchManager().watch(). */ VFSTask watch( const URI& root, const WatchOptions& options = {} ); diff --git a/src/modules/vfs/src/eepp/vfs/transferengine.cpp b/src/modules/vfs/src/eepp/vfs/transferengine.cpp index ba94896fd..ddf2c076a 100644 --- a/src/modules/vfs/src/eepp/vfs/transferengine.cpp +++ b/src/modules/vfs/src/eepp/vfs/transferengine.cpp @@ -1,5 +1,7 @@ #include +#include +#include #include #include #include @@ -11,8 +13,8 @@ using detail::await; using detail::FileWriter; Transfer TransferEngine::start( VirtualFileSystem& vfs, std::shared_ptr pool, - URI source, URI destination, const CopyOptions& options, - bool move ) { + URI source, URI destination, const CopyOptions& options, bool move, + TransferConflictResolver onConflict ) { auto state = std::make_shared(); Transfer transfer( state ); if ( !pool ) { @@ -20,20 +22,23 @@ Transfer TransferEngine::start( VirtualFileSystem& vfs, std::shared_ptr( vfs, state, std::move( source ), - std::move( destination ), options, move ); + auto engine = + std::make_shared( vfs, state, std::move( source ), std::move( destination ), + options, move, std::move( onConflict ) ); pool->run( [engine = std::move( engine ), state]() { state->complete( engine->run() ); } ); return transfer; } TransferEngine::TransferEngine( VirtualFileSystem& vfs, std::shared_ptr state, URI source, - URI destination, CopyOptions options, bool move ) : + URI destination, CopyOptions options, bool move, + TransferConflictResolver onConflict ) : mVfs( vfs ), mState( std::move( state ) ), mSource( std::move( source ) ), mDestination( std::move( destination ) ), mOptions( options ), + mOnConflict( std::move( onConflict ) ), mToken( mState->token() ), mMove( move ) { if ( mOptions.chunkSize == 0 ) @@ -78,10 +83,54 @@ VFSResult TransferEngine::run() { } } + if ( mMove && mSameFileSystem && mOnConflict ) { + for ( ;; ) { + auto existing = + await( mDstProvider->stat( mDestination, StatOptions{ false }, mToken ) ); + if ( !existing ) { + if ( !existing.error().is( VFSErrorCode::NotFound ) ) + return Err( std::move( existing ).error() ); + break; + } + auto info = await( mSrcProvider->stat( mSource, StatOptions{ false }, mToken ) ); + if ( !info ) + return Err( std::move( info ).error() ); + if ( mOptions.overwrite == OverwritePolicy::MergeDirectories && + info.value().isDirectory() && existing.value().isDirectory() ) { + mRootConflictResolved = true; + break; + } + auto resolved = + resolveConflict( info.value(), existing.value(), mSource, mDestination ); + if ( !resolved ) + return Err( std::move( resolved ).error() ); + const auto& decision = resolved.value(); + if ( decision.action == ConflictAction::Skip ) { + ++mResult.filesSkipped; + return Ok( mResult ); + } + if ( decision.action == ConflictAction::Rename ) { + auto renamed = + renamedDestination( mDestination, decision.name, info.value().isDirectory() ); + if ( !renamed ) + return Err( std::move( renamed ).error() ); + mDestination = std::move( renamed ).value(); + continue; + } + if ( info.value().isDirectory() != existing.value().isDirectory() ) + return Err( VFSError( existing.value().isDirectory() ? VFSErrorCode::IsDirectory + : VFSErrorCode::NotDirectory, + mDestination ) ); + mRootConflictResolved = true; + break; + } + } if ( mMove && mSameFileSystem ) { auto renamed = await( mSrcProvider->rename( mSource, mDestination, - RenameOptions{ mOptions.overwrite == OverwritePolicy::Overwrite }, mToken ) ); + RenameOptions{ mRootConflictResolved || + ( !mOnConflict && mOptions.overwrite == OverwritePolicy::Overwrite ) }, + mToken ) ); if ( renamed ) { mResult.renamed = true; mState->updateProgress( @@ -95,11 +144,11 @@ VFSResult TransferEngine::run() { return Ok( mResult ); } const auto code = renamed.error().code; - bool fallback = - code == VFSErrorCode::Unsupported || code == VFSErrorCode::NotEmpty || - ( code == VFSErrorCode::AlreadyExists && - mOptions.overwrite != OverwritePolicy::Fail ) || - ( code == VFSErrorCode::IsDirectory && mOptions.overwrite != OverwritePolicy::Fail ); + bool fallback = code == VFSErrorCode::Unsupported || code == VFSErrorCode::NotEmpty || + ( code == VFSErrorCode::AlreadyExists && + ( mOptions.overwrite != OverwritePolicy::Fail || mOnConflict ) ) || + ( code == VFSErrorCode::IsDirectory && + ( mOptions.overwrite != OverwritePolicy::Fail || mOnConflict ) ); if ( !fallback ) return Err( std::move( renamed ).error() ); } @@ -127,7 +176,7 @@ VFSResult TransferEngine::run() { } if ( rootInfo.value().isDirectory() && mSameFileSystem ) { - if ( auto checked = checkDirectoryDestination(); !checked ) + if ( auto checked = checkDirectoryDestination( mSource, mDestination ); !checked ) return Err( std::move( checked ).error() ); } @@ -172,20 +221,21 @@ VFSResult TransferEngine::checkSource( const VFSFileInfo& info, const URI& return Ok(); } -VFSResult TransferEngine::checkDirectoryDestination() { +VFSResult TransferEngine::checkDirectoryDestination( const URI& sourceURI, + const URI& destinationURI ) { auto invalid = [&] { - return Err( VFSError( VFSErrorCode::InvalidOperation, mDestination, + return Err( VFSError( VFSErrorCode::InvalidOperation, destinationURI, "Cannot copy a directory into itself" ) ); }; - if ( mDestination.isSameOrChildOf( mSource ) ) + if ( destinationURI.isSameOrChildOf( sourceURI ) ) return invalid(); - auto source = await( mSrcProvider->canonicalize( mSource, mToken ) ); + auto source = await( mSrcProvider->canonicalize( sourceURI, mToken ) ); if ( !source ) { if ( source.error().is( VFSErrorCode::Unsupported ) ) return Ok(); return Err( std::move( source ).error() ); } - URI ancestor = mDestination; + URI ancestor = destinationURI; std::vector missing; while ( true ) { auto resolved = await( mDstProvider->canonicalize( ancestor, mToken ) ); @@ -258,8 +308,8 @@ VFSResult TransferEngine::scan( Node& node, const URI& uri, const URI& des return Ok(); } -VFSResult TransferEngine::copyNode( const Node& node, const URI& source, - const URI& destination, int depth ) { +VFSResult TransferEngine::copyNode( const Node& node, const URI& source, URI destination, + int depth ) { if ( auto c = checkCancelled( source ); !c ) return Err( std::move( c ).error() ); const VFSFileInfo& info = node.info; @@ -283,45 +333,90 @@ VFSResult TransferEngine::copyNode( const Node& node, const URI& source, return Ok( false ); } - auto existing = await( mDstProvider->stat( destination, StatOptions{}, mToken ) ); - if ( existing ) { - if ( existing.value().isDirectory() ) - return Err( VFSError( VFSErrorCode::IsDirectory, destination ) ); - if ( mOptions.overwrite == OverwritePolicy::Fail || - mOptions.overwrite == OverwritePolicy::MergeDirectories ) - return Err( VFSError( VFSErrorCode::AlreadyExists, destination ) ); - if ( mOptions.overwrite == OverwritePolicy::Skip ) { - ++mResult.filesSkipped; - advanceFiles( info ); - return Ok( false ); + bool overwrite = ( !mOnConflict && mOptions.overwrite == OverwritePolicy::Overwrite ) || + ( depth == 0 && mRootConflictResolved ); + for ( ;; ) { + auto existing = await( mDstProvider->stat( destination, StatOptions{}, mToken ) ); + if ( existing ) { + if ( mOnConflict && !( depth == 0 && mRootConflictResolved ) ) { + auto resolved = resolveConflict( info, existing.value(), source, destination ); + if ( !resolved ) + return Err( std::move( resolved ).error() ); + const auto& decision = resolved.value(); + if ( decision.action == ConflictAction::Rename ) { + auto renamed = renamedDestination( destination, decision.name, false ); + if ( !renamed ) + return Err( std::move( renamed ).error() ); + destination = std::move( renamed ).value(); + overwrite = false; + continue; + } + if ( decision.action == ConflictAction::Skip ) { + ++mResult.filesSkipped; + advanceFiles( info ); + return Ok( false ); + } + overwrite = true; + } else if ( !mOnConflict ) { + if ( existing.value().isDirectory() ) + return Err( VFSError( VFSErrorCode::IsDirectory, destination ) ); + if ( mOptions.overwrite == OverwritePolicy::Fail || + mOptions.overwrite == OverwritePolicy::MergeDirectories ) + return Err( VFSError( VFSErrorCode::AlreadyExists, destination ) ); + if ( mOptions.overwrite == OverwritePolicy::Skip ) { + ++mResult.filesSkipped; + advanceFiles( info ); + return Ok( false ); + } + } + if ( mSameFileSystem ) { + auto same = isSameFile( source, destination ); + if ( !same ) + return Err( std::move( same ).error() ); + if ( same.value() ) + return Err( VFSError( VFSErrorCode::InvalidOperation, destination, + "Source and destination are the same file" ) ); + } + if ( existing.value().isDirectory() ) + return Err( VFSError( VFSErrorCode::IsDirectory, destination ) ); + } else if ( !existing.error().is( VFSErrorCode::NotFound ) ) { + return Err( std::move( existing ).error() ); } - if ( mSameFileSystem ) { - auto same = isSameFile( source, destination ); - if ( !same ) - return Err( std::move( same ).error() ); - if ( same.value() ) { - return Err( VFSError( VFSErrorCode::InvalidOperation, destination, - "Source and destination are the same file" ) ); + + if ( mMove ) { + if ( auto checked = checkSource( info, source ); !checked ) + return Err( std::move( checked ).error() ); + // A conflict decision can still use a native move, including an auto-renamed target. + if ( mSameFileSystem && mOnConflict ) { + auto renamed = await( mSrcProvider->rename( source, destination, + RenameOptions{ overwrite }, mToken ) ); + if ( renamed ) { + mResult.renamed = depth == 0; + ++mResult.filesTransferred; + ++mResult.sourcesRemoved; + advanceFiles( info ); + return Ok( true ); + } + if ( renamed.error().is( VFSErrorCode::AlreadyExists ) ) + continue; + if ( !renamed.error().is( VFSErrorCode::Unsupported ) ) + return Err( std::move( renamed ).error() ); } } - } else if ( !existing.error().is( VFSErrorCode::NotFound ) ) { - return Err( std::move( existing ).error() ); - } - - if ( mMove ) { - if ( auto checked = checkSource( info, source ); !checked ) - return Err( std::move( checked ).error() ); - } - if ( auto copied = copyFile( info, source, destination ); !copied ) { - // A destination can appear while the temporary is written. Skip still applies at - // publication time, and a move must keep that source. - if ( mOptions.overwrite == OverwritePolicy::Skip && - copied.error().is( VFSErrorCode::AlreadyExists ) ) { - ++mResult.filesSkipped; - advanceFiles( info ); - return Ok( false ); + if ( auto copied = copyFile( info, source, destination, overwrite ); !copied ) { + // Resolve destinations which appeared while writing, without replaying completed files. + if ( copied.error().is( VFSErrorCode::AlreadyExists ) ) { + if ( mOnConflict ) + continue; + if ( mOptions.overwrite == OverwritePolicy::Skip ) { + ++mResult.filesSkipped; + advanceFiles( info ); + return Ok( false ); + } + } + return Err( std::move( copied ).error() ); } - return Err( std::move( copied ).error() ); + break; } ++mResult.filesTransferred; advanceFiles( info, false ); @@ -339,30 +434,58 @@ VFSResult TransferEngine::copyNode( const Node& node, const URI& source, return Ok( true ); } -VFSResult TransferEngine::copyDirectory( const Node& node, const URI& source, - const URI& destination, int depth ) { +VFSResult TransferEngine::copyDirectory( const Node& node, const URI& source, URI destination, + int depth ) { if ( mMove ) { if ( auto checked = checkSource( node.info, source ); !checked ) return Err( std::move( checked ).error() ); } - auto existing = await( mDstProvider->stat( destination, StatOptions{}, mToken ) ); - if ( existing ) { - if ( !existing.value().isDirectory() ) - return Err( VFSError( VFSErrorCode::NotDirectory, destination ) ); - if ( mOptions.overwrite == OverwritePolicy::Fail ) - return Err( VFSError( VFSErrorCode::AlreadyExists, destination ) ); - } else if ( !existing.error().is( VFSErrorCode::NotFound ) ) { - return Err( std::move( existing ).error() ); - } else { - MkDirOptions mkdir; - if ( mOptions.preservePermissions && - mDstProvider->capabilities( destination ).has( VFSCapability::Permissions ) ) { - mkdir.permissions = node.info.permissions(); + for ( ;; ) { + auto existing = await( mDstProvider->stat( destination, StatOptions{}, mToken ) ); + if ( existing ) { + if ( mOnConflict && !( depth == 0 && mRootConflictResolved ) && + ( mOptions.overwrite != OverwritePolicy::MergeDirectories || + !existing.value().isDirectory() ) ) { + auto resolved = resolveConflict( node.info, existing.value(), source, destination ); + if ( !resolved ) + return Err( std::move( resolved ).error() ); + const auto& decision = resolved.value(); + if ( decision.action == ConflictAction::Rename ) { + auto renamed = renamedDestination( destination, decision.name, true ); + if ( !renamed ) + return Err( std::move( renamed ).error() ); + destination = std::move( renamed ).value(); + continue; + } + if ( decision.action == ConflictAction::Skip ) { + ++mResult.filesSkipped; + return Ok( false ); + } + } else if ( !mOnConflict ) { + if ( !existing.value().isDirectory() ) + return Err( VFSError( VFSErrorCode::NotDirectory, destination ) ); + if ( mOptions.overwrite == OverwritePolicy::Fail ) + return Err( VFSError( VFSErrorCode::AlreadyExists, destination ) ); + } + if ( !existing.value().isDirectory() ) + return Err( VFSError( VFSErrorCode::NotDirectory, destination ) ); + } else if ( !existing.error().is( VFSErrorCode::NotFound ) ) { + return Err( std::move( existing ).error() ); + } else { + MkDirOptions mkdir; + if ( mOptions.preservePermissions && + mDstProvider->capabilities( destination ).has( VFSCapability::Permissions ) ) { + mkdir.permissions = node.info.permissions(); + } + auto made = await( mDstProvider->mkdir( destination, mkdir, mToken ) ); + if ( !made ) { + if ( mOnConflict && made.error().is( VFSErrorCode::AlreadyExists ) ) + continue; + return Err( std::move( made ).error() ); + } + ++mResult.directoriesCreated; } - auto made = await( mDstProvider->mkdir( destination, mkdir, mToken ) ); - if ( !made ) - return Err( std::move( made ).error() ); - ++mResult.directoriesCreated; + break; } bool complete = true; @@ -411,8 +534,73 @@ VFSResult TransferEngine::isSameFile( const URI& source, const URI& destin return Ok( canonicalSource.value().getPath() == canonicalDestination.value().getPath() ); } +VFSResult TransferEngine::resolveConflict( const VFSFileInfo& sourceInfo, + const VFSFileInfo& destinationInfo, + const URI& source, + const URI& destination ) { + if ( auto cancelled = checkCancelled( source ); !cancelled ) + return Err( std::move( cancelled ).error() ); + TransferConflict conflict{ source, destination, sourceInfo, destinationInfo }; + auto task = mOnConflict( std::move( conflict ), mToken ); + while ( task.waitFor( std::chrono::milliseconds( 20 ) ) == TaskState::Pending ) { + if ( auto cancelled = checkCancelled( source ); !cancelled ) + return Err( std::move( cancelled ).error() ); + } + if ( auto cancelled = checkCancelled( source ); !cancelled ) + return Err( std::move( cancelled ).error() ); + auto resolved = await( std::move( task ) ); + if ( !resolved ) + return resolved; + auto& decision = resolved.value(); + if ( decision.action == ConflictAction::Cancel ) + return Err( cancelledError( source ) ); + if ( decision.action == ConflictAction::OverwriteOlder ) { + const auto src = sourceInfo.modifiedTime(); + const auto dst = destinationInfo.modifiedTime(); + decision.action = + src && dst && *src > *dst && !sourceInfo.isDirectory() && !destinationInfo.isDirectory() + ? ConflictAction::Overwrite + : ConflictAction::Skip; + } + return resolved; +} + +VFSResult TransferEngine::renamedDestination( const URI& destination, const std::string& name, + bool directory ) { + auto validate = [this, directory]( URI renamed ) -> VFSResult { + if ( renamed == mSource ) + return Err( VFSError( VFSErrorCode::InvalidOperation, renamed, + "Source and destination must remain distinct" ) ); + if ( directory && mSameFileSystem ) { + if ( auto checked = checkDirectoryDestination( mSource, renamed ); !checked ) + return Err( std::move( checked ).error() ); + } + return Ok( std::move( renamed ) ); + }; + if ( !name.empty() ) { + if ( !VFSPath::validName( name, destination.getParent() ) || + name == destination.getLastPathSegment() ) + return Err( VFSError( VFSErrorCode::InvalidURI, destination, "Invalid new name" ) ); + return validate( destination.getParent().withAppendedSegment( name ) ); + } + const auto original = destination.getLastPathSegment(); + auto parts = directory ? std::make_pair( original, std::string() ) + : System::FileSystem::fileNameSplitExtension( original ); + for ( Uint64 number = 1;; ++number ) { + if ( auto cancelled = checkCancelled( destination ); !cancelled ) + return Err( std::move( cancelled ).error() ); + URI candidate = destination.getParent().withAppendedSegment( + parts.first + " (" + String::toString( number ) + ")" + parts.second ); + auto existing = await( mDstProvider->stat( candidate, StatOptions{ false }, mToken ) ); + if ( !existing && existing.error().is( VFSErrorCode::NotFound ) ) + return validate( std::move( candidate ) ); + if ( !existing ) + return Err( std::move( existing ).error() ); + } +} + VFSResult TransferEngine::copyFile( const VFSFileInfo& info, const URI& source, - const URI& destination ) { + const URI& destination, bool overwrite ) { mState->updateProgress( [&]( TransferProgress& p ) { p.currentSource = source; @@ -432,12 +620,12 @@ VFSResult TransferEngine::copyFile( const VFSFileInfo& info, const URI& so FileWriter::ReplaceOptions replace; if ( mOptions.preservePermissions && info.permissions() ) replace.exactPermissions = info.permissions(); - replace.overwrite = mOptions.overwrite == OverwritePolicy::Overwrite; + replace.overwrite = overwrite; std::vector buffer( mOptions.chunkSize ); + Uint64 offset = 0; auto copied = FileWriter::replace( *mDstProvider, target.value(), replace, mToken, [&]( FileHandle& dst ) -> VFSResult { - Uint64 offset = 0; while ( true ) { if ( auto c = checkCancelled( source ); !c ) return c; @@ -453,12 +641,18 @@ VFSResult TransferEngine::copyFile( const VFSFileInfo& info, const URI& so return written; } offset += got; - mResult.bytesTransferred += got; mState->updateProgress( [got]( TransferProgress& p ) { p.bytesDone += got; } ); } }, []() -> VFSResult { return Ok(); } ); await( src.close() ); + if ( copied ) { + mResult.bytesTransferred += offset; + } else if ( offset > 0 ) { + // The temporary was discarded. Do not count it again if a publication conflict + // resolves to retry or skip this entry. + mState->updateProgress( [offset]( TransferProgress& p ) { p.bytesDone -= offset; }, true ); + } return copied; } diff --git a/src/modules/vfs/src/eepp/vfs/transferengine.hpp b/src/modules/vfs/src/eepp/vfs/transferengine.hpp index 96f85d8ed..14407c174 100644 --- a/src/modules/vfs/src/eepp/vfs/transferengine.hpp +++ b/src/modules/vfs/src/eepp/vfs/transferengine.hpp @@ -24,10 +24,12 @@ class TransferEngine { /** Runs a copy (or a move) on pool and returns its handle. Without a pool (the VFS is shutting * down) the transfer completes with an error. */ static Transfer start( VirtualFileSystem& vfs, std::shared_ptr pool, - URI source, URI destination, const CopyOptions& options, bool move ); + URI source, URI destination, const CopyOptions& options, bool move, + TransferConflictResolver onConflict = {} ); TransferEngine( VirtualFileSystem& vfs, std::shared_ptr state, - URI source, URI destination, CopyOptions options, bool move ); + URI source, URI destination, CopyOptions options, bool move, + TransferConflictResolver onConflict ); /** Blocks until the transfer finished. */ VFSResult run(); @@ -47,16 +49,15 @@ class TransferEngine { /** Includes aliases through existing destination ancestors, when canonicalization is supported. */ - VFSResult checkDirectoryDestination(); + VFSResult checkDirectoryDestination( const URI& sourceURI, const URI& destinationURI ); VFSResult scan( Node& node, const URI& uri, const URI& destination, int depth, Uint64& files, Uint64& bytes ); /** @return True if the entry (and everything below it) was fully copied. */ - VFSResult copyNode( const Node& node, const URI& source, const URI& destination, - int depth ); + VFSResult copyNode( const Node& node, const URI& source, URI destination, int depth ); - VFSResult copyDirectory( const Node& node, const URI& source, const URI& destination, + VFSResult copyDirectory( const Node& node, const URI& source, URI destination, int depth ); void advanceFiles( const VFSFileInfo& info, bool addBytes = true ); @@ -65,13 +66,22 @@ class TransferEngine { * not detected; FileWriter::replace() keeps their data safe. */ VFSResult isSameFile( const URI& source, const URI& destination ); - VFSResult copyFile( const VFSFileInfo& info, const URI& source, const URI& destination ); + VFSResult resolveConflict( const VFSFileInfo& sourceInfo, + const VFSFileInfo& destinationInfo, + const URI& source, const URI& destination ); + + VFSResult renamedDestination( const URI& destination, const std::string& name, + bool directory ); + + VFSResult copyFile( const VFSFileInfo& info, const URI& source, const URI& destination, + bool overwrite ); VirtualFileSystem& mVfs; std::shared_ptr mState; URI mSource; URI mDestination; CopyOptions mOptions; + TransferConflictResolver mOnConflict; CancellationToken mToken; VFSProvider* mSrcProvider{ nullptr }; VFSProvider* mDstProvider{ nullptr }; @@ -79,6 +89,8 @@ class TransferEngine { bool mMove; /** Source and destination canonical paths belong to the same provider namespace. */ bool mSameFileSystem{ false }; + /** A root move collision was accepted before trying the native rename. */ + bool mRootConflictResolved{ false }; }; }} // namespace EE::VFS diff --git a/src/modules/vfs/src/eepp/vfs/virtualfilesystem.cpp b/src/modules/vfs/src/eepp/vfs/virtualfilesystem.cpp index 76a7bd086..1dd347a7e 100644 --- a/src/modules/vfs/src/eepp/vfs/virtualfilesystem.cpp +++ b/src/modules/vfs/src/eepp/vfs/virtualfilesystem.cpp @@ -503,9 +503,23 @@ Transfer VirtualFileSystem::copy( const URI& source, const URI& destination, return TransferEngine::start( *this, mTransferPool, source, destination, options, false ); } +Transfer VirtualFileSystem::copy( const URI& source, const URI& destination, + const CopyOptions& options, + TransferConflictResolver onConflict ) { + return TransferEngine::start( *this, mTransferPool, source, destination, options, false, + std::move( onConflict ) ); +} + Transfer VirtualFileSystem::move( const URI& source, const URI& destination, const CopyOptions& options ) { return TransferEngine::start( *this, mTransferPool, source, destination, options, true ); } +Transfer VirtualFileSystem::move( const URI& source, const URI& destination, + const CopyOptions& options, + TransferConflictResolver onConflict ) { + return TransferEngine::start( *this, mTransferPool, source, destination, options, true, + std::move( onConflict ) ); +} + }} // namespace EE::VFS diff --git a/src/tests/unit_tests/vfs_tests.cpp b/src/tests/unit_tests/vfs_tests.cpp index 2cda9e4da..20ed759e2 100644 --- a/src/tests/unit_tests/vfs_tests.cpp +++ b/src/tests/unit_tests/vfs_tests.cpp @@ -568,6 +568,287 @@ UTEST( VFSTransfer, directoryMergeRequiresExplicitFileOverwrite ) { EXPECT_STDSTREQ( "new", readString( vfs, file ) ); } +UTEST( VFSTransfer, resolvesNestedConflictsWithoutRestartingTheTree ) { + VirtualFileSystem vfs; + auto mem = std::make_shared(); + vfs.registerProvider( mem ); + for ( const auto* name : { "a.txt", "b.txt", "c.txt" } ) { + mem->putFile( URI( std::string( "mem://test/source/sub/" ) + name ), "new" ); + mem->putFile( URI( std::string( "mem://test/destination/sub/" ) + name ), "old" ); + } + int files = 0, folders = 0; + CopyOptions options; + TransferConflictResolver onConflict; + onConflict = [&]( const TransferConflict& conflict, CancellationToken ) { + ConflictResolution decision; + if ( conflict.sourceInfo.isDirectory() ) { + ++folders; + decision.action = ConflictAction::Overwrite; + } else { + ++files; + const auto name = conflict.source.getLastPathSegment(); + decision.action = name == "a.txt" ? ConflictAction::Skip + : name == "b.txt" ? ConflictAction::Overwrite + : ConflictAction::Rename; + } + return readyValue( std::move( decision ) ); + }; + auto result = waitFor( + vfs.copy( URI( "mem://test/source" ), URI( "mem://test/destination" ), options, onConflict ) + .completion() ); + ASSERT_TRUE( result ); + EXPECT_EQ( 2, folders ); + EXPECT_EQ( 3, files ); + EXPECT_EQ( 2u, result.value().filesTransferred ); + EXPECT_EQ( 1u, result.value().filesSkipped ); + EXPECT_STDSTREQ( "old", readString( vfs, URI( "mem://test/destination/sub/a.txt" ) ) ); + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/destination/sub/b.txt" ) ) ); + EXPECT_STDSTREQ( "old", readString( vfs, URI( "mem://test/destination/sub/c.txt" ) ) ); + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/destination/sub/c (1).txt" ) ) ); +} + +UTEST( VFSTransfer, interactiveMoveKeepsSkippedSourcesAndRenamesWithoutConflicts ) { + VirtualFileSystem vfs; + auto mem = std::make_shared(); + vfs.registerProvider( mem ); + mem->putFile( URI( "mem://test/source/a" ), "new" ); + mem->putFile( URI( "mem://test/destination/a" ), "old" ); + CopyOptions options; + TransferConflictResolver onConflict; + onConflict = []( const TransferConflict&, CancellationToken ) { + return readyValue( ConflictResolution{ {}, ConflictAction::Skip } ); + }; + auto skipped = waitFor( vfs.move( URI( "mem://test/source/a" ), + URI( "mem://test/destination/a" ), options, onConflict ) + .completion() ); + ASSERT_TRUE( skipped ); + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/source/a" ) ) ); + EXPECT_STDSTREQ( "old", readString( vfs, URI( "mem://test/destination/a" ) ) ); + auto moved = waitFor( vfs.move( URI( "mem://test/source/a" ), URI( "mem://test/destination/b" ), + options, onConflict ) + .completion() ); + ASSERT_TRUE( moved ); + EXPECT_TRUE( moved.value().renamed ); + EXPECT_EQ( VFSErrorCode::NotFound, + codeOf( waitFor( vfs.stat( URI( "mem://test/source/a" ) ) ) ) ); +} + +UTEST( VFSTransfer, interactiveOverwriteUsesNativeMovesAndRejectsTypeReplacement ) { + VirtualFileSystem vfs; + auto mem = std::make_shared(); + vfs.registerProvider( mem ); + mem->putFile( URI( "mem://test/source" ), "new" ); + mem->putFile( URI( "mem://test/destination" ), "old" ); + CopyOptions options; + TransferConflictResolver onConflict; + onConflict = []( const TransferConflict&, CancellationToken ) { + return readyValue( + ConflictResolution{ {}, ConflictAction::Overwrite } ); + }; + auto moved = waitFor( + vfs.move( URI( "mem://test/source" ), URI( "mem://test/destination" ), options, onConflict ) + .completion() ); + ASSERT_TRUE( moved ); + EXPECT_TRUE( moved.value().renamed ); + EXPECT_EQ( 0u, moved.value().bytesTransferred ); + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/destination" ) ) ); + mem->putDirectory( URI( "mem://test/folder" ) ); + EXPECT_EQ( VFSErrorCode::IsDirectory, + codeOf( waitFor( vfs.copy( URI( "mem://test/destination" ), + URI( "mem://test/folder" ), options, onConflict ) + .completion() ) ) ); + EXPECT_EQ( VFSErrorCode::NotDirectory, + codeOf( waitFor( vfs.copy( URI( "mem://test/folder" ), + URI( "mem://test/destination" ), options, onConflict ) + .completion() ) ) ); + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/destination" ) ) ); +} + +UTEST( VFSTransfer, interactiveDirectoryMoveAsksOncePerConflictAndKeepsSkippedEntries ) { + VirtualFileSystem vfs; + auto mem = std::make_shared(); + vfs.registerProvider( mem ); + mem->putFile( URI( "mem://test/source/skip" ), "new" ); + mem->putFile( URI( "mem://test/source/move" ), "move" ); + mem->putFile( URI( "mem://test/destination/skip" ), "old" ); + int folders = 0, files = 0; + TransferConflictResolver onConflict = [&]( const TransferConflict& conflict, + CancellationToken ) { + const bool folder = conflict.sourceInfo.isDirectory(); + folder ? ++folders : ++files; + return readyValue( + ConflictResolution{ {}, folder ? ConflictAction::Overwrite : ConflictAction::Skip } ); + }; + auto moved = waitFor( + vfs.move( URI( "mem://test/source" ), URI( "mem://test/destination" ), {}, onConflict ) + .completion() ); + ASSERT_TRUE( moved ); + EXPECT_EQ( 1, folders ); + EXPECT_EQ( 1, files ); + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/source/skip" ) ) ); + EXPECT_STDSTREQ( "old", readString( vfs, URI( "mem://test/destination/skip" ) ) ); + EXPECT_STDSTREQ( "move", readString( vfs, URI( "mem://test/destination/move" ) ) ); + EXPECT_EQ( VFSErrorCode::NotFound, + codeOf( waitFor( vfs.stat( URI( "mem://test/source/move" ) ) ) ) ); +} + +UTEST( VFSTransfer, overwriteOlderOnlyReplacesAnOlderDestination ) { + class TimedProvider : public MemoryProvider { + public: + Int64 destinationTime{ 40 }; + + VFSTask stat( const URI& uri, const StatOptions& options, + CancellationToken token ) override { + return MemoryProvider::stat( uri, options, token ) + .then( [time = uri.getLastPathSegment() == "source" ? 20 : destinationTime]( + VFSResult&& result ) { + if ( result ) + result.value().setModifiedTime( time ); + return std::move( result ); + } ); + } + }; + VirtualFileSystem vfs; + auto mem = std::make_shared(); + vfs.registerProvider( mem ); + mem->putFile( URI( "mem://test/source" ), "new" ); + mem->putFile( URI( "mem://test/destination" ), "old" ); + CopyOptions options; + TransferConflictResolver onConflict; + onConflict = []( const TransferConflict&, CancellationToken ) { + return readyValue( + ConflictResolution{ {}, ConflictAction::OverwriteOlder } ); + }; + auto skipped = waitFor( + vfs.copy( URI( "mem://test/source" ), URI( "mem://test/destination" ), options, onConflict ) + .completion() ); + ASSERT_TRUE( skipped ); + EXPECT_STDSTREQ( "old", readString( vfs, URI( "mem://test/destination" ) ) ); + mem->destinationTime = 10; + auto overwritten = waitFor( + vfs.copy( URI( "mem://test/source" ), URI( "mem://test/destination" ), options, onConflict ) + .completion() ); + ASSERT_TRUE( overwritten ); + EXPECT_EQ( 0u, overwritten.value().filesSkipped ); + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/destination" ) ) ); +} + +UTEST( VFSTransfer, conflictRenameValidatesNamesAndNeverReplacesExistingCandidates ) { + VirtualFileSystem vfs; + auto mem = std::make_shared(); + vfs.registerProvider( mem ); + mem->putFile( URI( "mem://test/source.txt" ), "new" ); + mem->putFile( URI( "mem://test/file.txt" ), "old" ); + mem->putFile( URI( "mem://test/file (1).txt" ), "keep" ); + CopyOptions options; + TransferConflictResolver onConflict; + onConflict = []( const TransferConflict&, CancellationToken ) { + return readyValue( ConflictResolution{ {}, ConflictAction::Rename } ); + }; + ASSERT_TRUE( waitFor( vfs.copy( URI( "mem://test/source.txt" ), URI( "mem://test/file.txt" ), + options, onConflict ) + .completion() ) ); + EXPECT_STDSTREQ( "keep", readString( vfs, URI( "mem://test/file (1).txt" ) ) ); + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/file (2).txt" ) ) ); + onConflict = []( const TransferConflict&, CancellationToken ) { + return readyValue( + ConflictResolution{ "../escape", ConflictAction::Rename } ); + }; + EXPECT_EQ( VFSErrorCode::InvalidURI, + codeOf( waitFor( vfs.copy( URI( "mem://test/source.txt" ), + URI( "mem://test/file.txt" ), options, onConflict ) + .completion() ) ) ); + EXPECT_STDSTREQ( "old", readString( vfs, URI( "mem://test/file.txt" ) ) ); +} + +UTEST( VFSTransfer, conflictRenameProtectsACandidateCreatedDuringPublication ) { + class RacingProvider : public MemoryProvider { + public: + VFSTask rename( const URI& from, const URI& to, const RenameOptions& options, + CancellationToken token ) override { + if ( !mCollided && to.getLastPathSegment() == "file (1).txt" ) { + mCollided = true; + putFile( to, "concurrent file" ); + } + return MemoryProvider::rename( from, to, options, std::move( token ) ); + } + + private: + bool mCollided{ false }; + }; + for ( const auto action : + { ConflictAction::Rename, ConflictAction::Skip, ConflictAction::Overwrite } ) { + VirtualFileSystem vfs; + auto mem = std::make_shared(); + vfs.registerProvider( mem ); + mem->putFile( URI( "mem://test/source.txt" ), "new" ); + mem->putFile( URI( "mem://test/file.txt" ), "old" ); + CopyOptions options; + options.overwrite = OverwritePolicy::Overwrite; + TaskResolver> resolve; + auto pending = VFSTask::create( resolve ); + TransferConflictResolver onConflict = [&, first = true]( const TransferConflict&, + CancellationToken ) mutable { + if ( first ) { + first = false; + return std::move( pending ); + } + return readyValue( ConflictResolution{ {}, action } ); + }; + auto transfer = vfs.copy( URI( "mem://test/source.txt" ), URI( "mem://test/file.txt" ), + options, onConflict ); + TransferProgress last; + bool bounded = true; + // Hold the first decision until subscribed, so even a fast worker is observed. + auto subscription = transfer.onProgress( [&]( const TransferProgress& progress ) { + last = progress; + bounded = + bounded && ( !progress.bytesTotal || progress.bytesDone <= *progress.bytesTotal ); + } ); + resolve( Ok( ConflictResolution{ {}, ConflictAction::Rename } ) ); + auto result = waitFor( transfer.completion() ); + ASSERT_TRUE( result ); + EXPECT_TRUE( bounded ); + EXPECT_EQ( 3u, last.bytesDone ); + EXPECT_EQ( 1u, last.filesDone ); + EXPECT_EQ( action == ConflictAction::Skip ? 0u : 3u, result.value().bytesTransferred ); + EXPECT_EQ( action == ConflictAction::Skip ? 1u : 0u, result.value().filesSkipped ); + EXPECT_STDSTREQ( "old", readString( vfs, URI( "mem://test/file.txt" ) ) ); + EXPECT_STDSTREQ( action == ConflictAction::Overwrite ? "new" : "concurrent file", + readString( vfs, URI( "mem://test/file (1).txt" ) ) ); + if ( action == ConflictAction::Rename ) { + EXPECT_STDSTREQ( "new", readString( vfs, URI( "mem://test/file (1) (1).txt" ) ) ); + } + } +} + +UTEST( VFSTransfer, cancellingWhileAConflictDecisionIsPendingDoesNotWaitForTheUI ) { + VirtualFileSystem vfs; + auto mem = std::make_shared(); + vfs.registerProvider( mem ); + mem->putFile( URI( "mem://test/source" ), "new" ); + mem->putFile( URI( "mem://test/destination" ), "old" ); + TaskResolver> resolve; + auto pending = VFSTask::create( resolve ); + std::atomic requested{ false }; + CopyOptions options; + TransferConflictResolver onConflict; + onConflict = [&]( const TransferConflict&, CancellationToken ) { + requested = true; + return std::move( pending ); + }; + auto transfer = vfs.copy( URI( "mem://test/source" ), URI( "mem://test/destination" ), options, + onConflict ); + auto completion = transfer.completion(); + TestLoop loop; + EXPECT_TRUE( loop.runUntil( [&] { return requested.load(); } ) ); + transfer.cancel(); + EXPECT_TRUE( loop.runUntil( [&] { return completion.isReady(); } ) ); + resolve( Ok( ConflictResolution{} ) ); + EXPECT_EQ( VFSErrorCode::Cancelled, codeOf( waitFor( std::move( completion ) ) ) ); + EXPECT_STDSTREQ( "old", readString( vfs, URI( "mem://test/destination" ) ) ); +} + UTEST( VFSTransfer, moveNeverRemovesUncopiedSources ) { VirtualFileSystem vfs; auto memA = std::make_shared( "mema" ); @@ -1035,6 +1316,45 @@ UTEST( VFSSafety, writeAllCreateParentsWithOneOrchestrationThread ) { #if EE_PLATFORM != EE_PLATFORM_WIN +UTEST( VFSTransfer, interactiveSymlinkMoveRemainsANativeRename ) { + TempDir tmp; + VirtualFileSystem vfs; + auto source = tmp.uri().withAppendedSegment( "source-link" ); + auto destination = tmp.uri().withAppendedSegment( "destination" ); + ASSERT_EQ( 0, ::symlink( "target", source.getFSPath().c_str() ) ); + ASSERT_TRUE( waitFor( vfs.writeAll( destination, "old" ) ) ); + TransferConflictResolver onConflict = []( const TransferConflict&, CancellationToken ) { + return readyValue( + ConflictResolution{ {}, ConflictAction::Overwrite } ); + }; + auto moved = waitFor( vfs.move( source, destination, {}, onConflict ).completion() ); + ASSERT_TRUE( moved ); + EXPECT_TRUE( moved.value().renamed ); + auto link = waitFor( vfs.stat( destination, StatOptions{ false } ) ); + ASSERT_TRUE( link ); + EXPECT_TRUE( link.value().isSymlink() ); + EXPECT_STDSTREQ( "target", link.value().symlinkTarget ); +} + +UTEST( VFSTransfer, renamedDirectoryCannotAliasItsSource ) { + TempDir tmp; + VirtualFileSystem vfs; + auto source = tmp.uri().withAppendedSegment( "source" ); + auto inside = source.withAppendedSegment( "inside" ); + auto destination = tmp.uri().withAppendedSegment( "destination" ); + auto alias = tmp.uri().withAppendedSegment( "alias" ); + ASSERT_TRUE( waitFor( vfs.makeDirs( inside ) ) ); + ASSERT_TRUE( waitFor( vfs.makeDir( destination ) ) ); + ASSERT_EQ( 0, ::symlink( inside.getFSPath().c_str(), alias.getFSPath().c_str() ) ); + TransferConflictResolver onConflict = []( const TransferConflict&, CancellationToken ) { + return readyValue( + ConflictResolution{ "alias", ConflictAction::Rename } ); + }; + auto copied = waitFor( vfs.copy( source, destination, {}, onConflict ).completion() ); + EXPECT_EQ( VFSErrorCode::InvalidOperation, codeOf( copied ) ); + EXPECT_TRUE( waitFor( vfs.stat( inside ) ).hasValue() ); +} + UTEST( VFSSafety, copyOntoAHardLinkKeepsTheSource ) { TempDir tmp; VirtualFileSystem vfs; diff --git a/src/tools/eefm/dialogs.cpp b/src/tools/eefm/dialogs.cpp index dabc55e8a..ab53e0e74 100644 --- a/src/tools/eefm/dialogs.cpp +++ b/src/tools/eefm/dialogs.cpp @@ -1,6 +1,11 @@ #include "dialogs.hpp" +#include "location.hpp" +#include +#include #include +#include #include +#include #include namespace eefm { @@ -67,4 +72,114 @@ void Dialogs::showMessage( const String& title, const String& message ) { box->showWhenReady(); } +UIMessageBox* +Dialogs::transferConflict( UISceneNode* ui, const EE::VFS::TransferConflict& conflict, + std::function done ) { + using namespace EE::VFS; + const bool folders = + conflict.sourceInfo.isDirectory() && conflict.destinationInfo.isDirectory(); + const bool sameType = + conflict.sourceInfo.isDirectory() == conflict.destinationInfo.isDirectory(); + const auto name = conflict.destination.getLastPathSegment(); + String message = String::format( + "\"%s\" already exists.\n\nSource: %s\nDestination: %s\n\n%s", name.c_str(), + Location::clipboardText( conflict.source ).c_str(), + Location::clipboardText( conflict.destination ).c_str(), + folders ? "Write into the existing folder? Existing files will be handled separately." + : sameType ? "Overwrite the destination, rename the incoming file, or skip it?" + : "The source and destination have different types. Rename or skip this item." ); + auto* box = UIMessageBox::New( UIMessageBox::INPUT, message ); + box->getTextBox()->setMaxWidthEq( "70vw" ); + box->getTextBox()->setWordWrap( true ); + box->setId( "transfer_conflict" ); + box->setTitle( ui->i18n( "eefm_file_exists", "File Already Exists" ) ); + auto* input = box->getTextInput(); + input->setId( "conflict_name" ); + input->setText( name ); + input->getDocument().selectAll(); + auto* applyAll = UICheckBox::New(); + applyAll->setId( "conflict_apply_all" ); + applyAll->setText( "Apply to All" ); + applyAll->setTooltipText( folders ? "Apply to subsequent folder conflicts in this operation. " + "File conflicts are handled separately." + : "Apply to subsequent file conflicts in this operation." ); + applyAll->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ); + applyAll->setLayoutMargin( Rectf( 0, 8, 0, 0 ) ); + applyAll->setParent( input->getParent() ); + applyAll->toPosition( 2 ); + using Answer = std::pair; + auto once = std::make_shared>(); + once->fn = [done = std::move( done )]( Answer answer ) mutable { + done( std::move( answer.first ), answer.second ); + }; + auto finish = [box, input, applyAll, once]( ConflictAction action ) { + ConflictResolution decision; + decision.action = action; + if ( action == ConflictAction::Rename && !applyAll->isChecked() ) + decision.name = input->getText().toUtf8(); + ( *once )( { std::move( decision ), applyAll->isChecked() } ); + if ( !box->isClosing() ) + box->closeWindow(); + }; + auto* overwrite = box->getButtonOK(); + overwrite->setId( "conflict_overwrite" ); + overwrite->setText( folders ? "Write Into" : "Overwrite" ); + auto* cancel = box->getButtonCancel(); + cancel->setId( "conflict_cancel" ); + cancel->setText( "Cancel" ); + auto addButton = [box]( const char* id, const char* text ) { + auto* button = UIPushButton::New(); + button->setId( id ); + button->setText( text ); + button->setLayoutSizePolicy( SizePolicy::WrapContent, SizePolicy::WrapContent ); + button->setLayoutMargin( Rectf( 8, 0, 0, 0 ) ); + button->setParent( box->getButtonOK()->getParent() ); + return button; + }; + auto* skip = addButton( "conflict_skip", "Skip" ); + skip->on( Event::MouseClick, [finish]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + finish( ConflictAction::Skip ); + } ); + auto* rename = addButton( "conflict_rename", "Rename" ); + rename->on( Event::MouseClick, [finish]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + finish( ConflictAction::Rename ); + } ); + UIPushButton* older = nullptr; + if ( !folders && sameType && conflict.sourceInfo.modifiedTime() && + conflict.destinationInfo.modifiedTime() ) { + older = addButton( "conflict_older", "Overwrite Older" ); + older->on( Event::MouseClick, [finish]( const Event* event ) { + if ( event->asMouseEvent()->getFlags() & EE_BUTTON_LMASK ) + finish( ConflictAction::OverwriteOlder ); + } ); + } + auto updateButtons = [input, applyAll, overwrite, older, rename, name, sameType, + parent = conflict.destination.getParent()]( const Event* ) { + const auto value = input->getText().toUtf8(); + overwrite->setEnabled( sameType && value == name ); + if ( older ) + older->setEnabled( value == name ); + rename->setText( applyAll->isChecked() ? "Auto Rename" : "Rename" ); + rename->setEnabled( applyAll->isChecked() || + ( value != name && VFSPath::validName( value, parent ) ) ); + }; + input->on( Event::OnTextChanged, updateButtons ); + applyAll->on( Event::OnValueChange, updateButtons ); + updateButtons( nullptr ); + box->on( Event::OnConfirm, [finish, overwrite, rename]( const Event* ) { + // Enter must honor the same filename validation as the buttons. + if ( overwrite->isEnabled() ) + finish( ConflictAction::Overwrite ); + else if ( rename->isEnabled() ) + finish( ConflictAction::Rename ); + } ); + box->on( Event::OnDiscard, [once]( const Event* ) { ( *once )( {} ); } ); + box->on( Event::OnWindowClose, [once]( const Event* ) { ( *once )( {} ); } ); + box->setCloseShortcut( { KEY_ESCAPE, KEYMOD_NONE } ); + box->showWhenReady(); + return box; +} + } // namespace eefm diff --git a/src/tools/eefm/dialogs.hpp b/src/tools/eefm/dialogs.hpp index f1fe1b5e4..ab2e0a5a9 100644 --- a/src/tools/eefm/dialogs.hpp +++ b/src/tools/eefm/dialogs.hpp @@ -1,7 +1,9 @@ #ifndef EEFM_DIALOGS_HPP #define EEFM_DIALOGS_HPP +#include #include +#include #include #include #include @@ -26,6 +28,11 @@ class Dialogs { static void showError( UISceneNode* ui, const String& message ); static void showMessage( const String& title, const String& message ); + + /** Resolve one transfer collision; the repeat choice applies only to the current batch. */ + static UIMessageBox* + transferConflict( UISceneNode* ui, const EE::VFS::TransferConflict& conflict, + std::function done ); }; } // namespace eefm diff --git a/src/tools/eefm/fileoperations.cpp b/src/tools/eefm/fileoperations.cpp index ef80900a8..d4335e0a2 100644 --- a/src/tools/eefm/fileoperations.cpp +++ b/src/tools/eefm/fileoperations.cpp @@ -20,15 +20,19 @@ FileOperations::FileOperations( App& app ) : mApp( app ), mUI( app.ui() ), mVFS( FileOperations::~FileOperations() = default; void FileOperations::cancelTransfers() { - for ( auto& transfer : mTransfers ) + for ( auto& transfer : mTransfers ) { + transfer.second.batch->cancelled = true; transfer.second.transfer.cancel(); + } mTransfers.clear(); } void FileOperations::cancelTransfers( int paneIndex ) { for ( auto& transfer : mTransfers ) { - if ( transfer.second.pane == paneIndex ) + if ( transfer.second.pane == paneIndex ) { + transfer.second.batch->cancelled = true; transfer.second.transfer.cancel(); + } } } @@ -700,6 +704,10 @@ void FileOperations::dropFiles( const std::vector& uris, const URI& destina void FileOperations::transferFiles( const std::vector& sources, const URI& destination, bool move, int paneIndex ) { + auto batch = std::make_shared(); + batch->entries.reserve( sources.size() ); + batch->pane = paneIndex; + batch->kind = move ? TransferKind::Move : TransferKind::Copy; for ( const auto& source : sources ) { std::string name = source.getLastPathSegment(); if ( !validateName( name, destination ) ) @@ -711,35 +719,100 @@ void FileOperations::transferFiles( const std::vector& sources, const URI& auto [base, extension] = FileSystem::fileNameSplitExtension( name ); target = destination.withAppendedSegment( base + " (copy)" + extension ); } - startTransfer( source, target, move ? TransferKind::Move : TransferKind::Copy, paneIndex ); + batch->entries.emplace_back( source, std::move( target ) ); } + startNextTransfer( batch ); } void FileOperations::extractArchive( const URI& file, const URI& destination, int paneIndex ) { URI root = Location::archiveRoot( file ); if ( root.empty() ) return; - CopyOptions options; - options.overwrite = OverwritePolicy::MergeDirectories; - // Copy the root as one transfer: entries are streamed sequentially, and an empty archive - // still creates its destination folder. Existing files require an explicit overwrite. - startTransfer( root, destination, TransferKind::Extract, paneIndex, options ); + auto batch = std::make_shared(); + batch->entries.emplace_back( std::move( root ), destination ); + batch->pane = paneIndex; + batch->kind = TransferKind::Extract; + startNextTransfer( batch ); } -void FileOperations::startTransfer( const URI& source, const URI& destination, TransferKind kind, - int paneIndex, const CopyOptions& options ) { +void FileOperations::startNextTransfer( const std::shared_ptr& batch ) { + if ( batch->cancelled || batch->next == batch->entries.size() ) + return; + const auto& entry = batch->entries[batch->next++]; + startTransfer( entry.first, entry.second, batch ); +} + +void FileOperations::startTransfer( const URI& source, const URI& destination, + const std::shared_ptr& batch ) { + const auto kind = batch->kind; + const int paneIndex = batch->pane; + CopyOptions options; + if ( kind == TransferKind::Extract ) + options.overwrite = OverwritePolicy::MergeDirectories; + batch->conflictsSkipped = 0; + auto onConflict = [this, batch, executor = &mApp.executor()]( TransferConflict conflict, + CancellationToken token ) { + TaskResolver> resolve; + auto task = VFSTask::create( resolve ); + // Own the request while it crosses from the transfer worker to the UI thread. + executor->execute( [this, batch, conflict = std::move( conflict ), token, + resolve = std::move( resolve )]() mutable { + if ( batch->cancelled || token.isCancellationRequested() ) { + resolve( Err( cancelledError( conflict.source ) ) ); + return; + } + const bool folder = conflict.sourceInfo.isDirectory(); + const auto srcTime = conflict.sourceInfo.modifiedTime(); + const auto dstTime = conflict.destinationInfo.modifiedTime(); + const bool sourceNewer = !folder && !conflict.destinationInfo.isDirectory() && + srcTime && dstTime && *srcTime > *dstTime; + auto answer = [batch, sourceNewer, + resolve = std::move( resolve )]( ConflictResolution decision ) mutable { + if ( decision.action == ConflictAction::Skip || + ( decision.action == ConflictAction::OverwriteOlder && !sourceNewer ) ) + ++batch->conflictsSkipped; + resolve( Ok( std::move( decision ) ) ); + }; + auto remembered = folder ? batch->folders : batch->files; + // A remembered overwrite never authorizes replacing a different entry type. + if ( remembered && + ( *remembered == ConflictAction::Skip || *remembered == ConflictAction::Rename || + conflict.sourceInfo.isDirectory() == conflict.destinationInfo.isDirectory() ) ) { + answer( ConflictResolution{ {}, *remembered } ); + return; + } + batch->dialog = Dialogs::transferConflict( + mUI, conflict, + [batch, folder, answer = std::move( answer )]( ConflictResolution decision, + bool applyAll ) mutable { + if ( decision.action == ConflictAction::Cancel ) + batch->cancelled = true; + else if ( applyAll ) + ( folder ? batch->folders : batch->files ) = decision.action; + answer( std::move( decision ) ); + } ); + batch->dialog->on( Event::OnWindowClose, + [batch, dialog = batch->dialog]( const Event* ) { + if ( batch->dialog == dialog ) + batch->dialog = nullptr; + } ); + } ); + return task; + }; const bool move = kind == TransferKind::Move; String label = kind == TransferKind::Extract ? mUI->i18n( "eefm_extracting", "Extracting " ) : move ? mUI->i18n( "eefm_moving", "Moving " ) : mUI->i18n( "eefm_copying", "Copying " ); Uint64 id = ++mLastTransferId; - ActiveTransfer active{ id, - paneIndex, - std::move( label ) + Location::displayName( source ), - move ? mVFS->move( source, destination, options ) - : mVFS->copy( source, destination, options ), - {}, - {} }; + ActiveTransfer active{ + id, + paneIndex, + std::move( label ) + Location::displayName( source ), + move ? mVFS->move( source, destination, options, std::move( onConflict ) ) + : mVFS->copy( source, destination, options, std::move( onConflict ) ), + {}, + {}, + batch }; active.subscription = active.transfer.onProgress( [this, id]( const TransferProgress& progress ) { auto it = mTransfers.find( id ); @@ -753,18 +826,19 @@ void FileOperations::startTransfer( const URI& source, const URI& destination, T mTransfers.emplace( id, std::move( active ) ); updateTransfers(); std::move( completion ) - .then( mApp.executor(), [this, id, source, destination, kind, paneIndex, - options]( VFSResult&& result ) { + .then( mApp.executor(), [this, id, destination, kind, + batch]( VFSResult&& result ) { auto it = mTransfers.find( id ); String label = it != mTransfers.end() ? it->second.label : String(); if ( it != mTransfers.end() ) mTransfers.erase( it ); updateTransfers(); if ( result ) { - if ( result.value().partial() ) { + if ( result.value().filesSkipped > batch->conflictsSkipped ) { Dialogs::showMessage( "Partial Transfer", label + ":\n" + - String::toString( result.value().filesSkipped ) + + String::toString( result.value().filesSkipped - + batch->conflictsSkipped ) + " entries were skipped (existing destinations, " "symbolic links, or unsupported " "file types). Skipped source entries were kept." ); @@ -780,31 +854,17 @@ void FileOperations::startTransfer( const URI& source, const URI& destination, T tab->model->refresh(); } } + startNextTransfer( batch ); return; } - if ( result.error().is( VFSErrorCode::AlreadyExists ) && - ( options.overwrite == OverwritePolicy::Fail || - options.overwrite == OverwritePolicy::MergeDirectories ) ) { - String question = - kind == TransferKind::Extract - ? mUI->i18n( "eefm_extract_overwrite", - "The extraction destination contains existing files. " - "Overwrite them?\n" ) + - Location::text( destination ) - : String( String::format( "\"%s\" already exists. Overwrite it?", - destination.getLastPathSegment().c_str() ) ); - Dialogs::confirm( - mUI, mUI->i18n( "eefm_file_exists", "File Already Exists" ), question, - [this, source, destination, kind, paneIndex, options]( bool overwrite ) { - if ( !overwrite ) - return; - CopyOptions retry = options; - retry.overwrite = OverwritePolicy::Overwrite; - startTransfer( source, destination, kind, paneIndex, retry ); - } ); + if ( result.error().is( VFSErrorCode::Cancelled ) ) { + batch->cancelled = true; + if ( batch->dialog && !batch->dialog->isClosing() ) + batch->dialog->closeWindow(); return; } reportError( label, result.error() ); + batch->cancelled = true; } ); } diff --git a/src/tools/eefm/fileoperations.hpp b/src/tools/eefm/fileoperations.hpp index 09e235855..bad9b0f92 100644 --- a/src/tools/eefm/fileoperations.hpp +++ b/src/tools/eefm/fileoperations.hpp @@ -53,8 +53,7 @@ class FileOperations { void openTerminalHere( FileTab* tab ); - /** Moves or copies (as the user picks) the dropped files to destination; with copy, they are - * copied without asking. Files already there are skipped. */ + /** Moves or copies dropped files to destination, resolving conflicts within one batch. */ void dropFiles( const std::vector& uris, const URI& destination, int paneIndex, bool copy = false ); @@ -86,6 +85,19 @@ class FileOperations { private: enum class TransferKind : Uint8 { Copy, Move, Extract }; + /** One paste/drop/extraction: conflicts share decisions, and prompts are sequential. */ + struct TransferBatch { + std::vector> entries; + size_t next{ 0 }; + Uint64 conflictsSkipped{ 0 }; + UIWindow* dialog{ nullptr }; + std::optional files; + std::optional folders; + int pane{ 0 }; + TransferKind kind{ TransferKind::Copy }; + bool cancelled{ false }; + }; + struct ActiveTransfer { Uint64 id; int pane; @@ -93,6 +105,7 @@ class FileOperations { EE::VFS::Transfer transfer; EE::VFS::TransferProgressSubscription subscription; EE::VFS::TransferProgress progress; + std::shared_ptr batch; }; /** Where a drop lands: a folder row, else the folder of the pane under the pointer. */ @@ -139,8 +152,10 @@ class FileOperations { void extractArchive( const URI& file, const URI& destination, int paneIndex ); - void startTransfer( const URI& source, const URI& destination, TransferKind kind, int paneIndex, - const EE::VFS::CopyOptions& options = {} ); + void startNextTransfer( const std::shared_ptr& batch ); + + void startTransfer( const URI& source, const URI& destination, + const std::shared_ptr& batch ); void updateTransfers(); diff --git a/src/tools/eefm/fileview.cpp b/src/tools/eefm/fileview.cpp index e064d92c5..45ba107ef 100644 --- a/src/tools/eefm/fileview.cpp +++ b/src/tools/eefm/fileview.cpp @@ -398,6 +398,57 @@ FileTableView::FileTableView() : UITableView() { }; } +void FileTableView::beginNavigation() { + resetSearchText(); + if ( !mReplayingNavigation ) + mNavigationInput.clear(); + mLoading = true; + // The selected cell can disappear as soon as the model clears the previous directory. + if ( hasFocusWithin() ) + setFocus(); +} + +void FileTableView::endNavigation() { + mLoading = false; + if ( mReplayingNavigation || mNavigationInput.empty() ) + return; + if ( !hasFocusWithin() ) { + mNavigationInput.clear(); + return; + } + mReplayingNavigation = true; + size_t consumed = 0; + while ( !mLoading && consumed < mNavigationInput.size() ) { + const auto character = mNavigationInput[consumed++]; + if ( character == '\n' ) { + const auto index = getSelection().first(); + if ( !index.isValid() ) { + consumed = mNavigationInput.size(); + break; + } + onOpenModelIndex( index ); + } else { + UITableView::onTextInput( TextInputEvent( this, Event::TextInput, character, 0 ) ); + } + } + mNavigationInput.erase( 0, consumed ); + mReplayingNavigation = false; +} + +Uint32 FileTableView::onTextInput( const TextInputEvent& event ) { + if ( mLoading && !isEditing() ) { + mNavigationInput += event.getText(); + return 1; + } + return UITableView::onTextInput( event ); +} + +void FileTableView::onWidgetFocusLoss() { + // Abandon pending commands even if focus returns before the listing finishes. + mNavigationInput.clear(); + UITableView::onWidgetFocusLoss(); +} + void FileTableView::beginRename( size_t row ) { if ( !getModel() || row >= getModel()->rowCount() ) return; @@ -458,6 +509,13 @@ Uint32 FileTableView::onMessage( const NodeMessage* msg ) { } Uint32 FileTableView::onKeyDown( const KeyEvent& event ) { + if ( mLoading && ( event.getKeyCode() == KEY_RETURN || event.getKeyCode() == KEY_KP_ENTER ) && + !isEditing() && event.getSanitizedMod() == 0 ) { + mNavigationInput += '\n'; + return 1; + } + if ( event.getKeyCode() == KEY_ESCAPE ) + mNavigationInput.clear(); // Escape clears the selection (as Dolphin); with nothing selected it is left to others. if ( event.getKeyCode() == KEY_ESCAPE && !isEditing() && !( event.getMod() & ( KEYMOD_CTRL | KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_META ) ) && diff --git a/src/tools/eefm/fileview.hpp b/src/tools/eefm/fileview.hpp index c00f41d12..a775c6a1d 100644 --- a/src/tools/eefm/fileview.hpp +++ b/src/tools/eefm/fileview.hpp @@ -59,6 +59,11 @@ class FileTableView : public UITableView { /** @return The row of a widget inside this view (cells or rows), if any. */ std::optional rowOf( Node* node ) const; + /** Reset type-to-select for a new location and retain input until its listing is ready. */ + void beginNavigation(); + + void endNavigation(); + protected: FileTableView(); @@ -69,6 +74,15 @@ class FileTableView : public UITableView { Uint32 onMouseClick( const Vector2i& position, const Uint32& flags ) override; Uint32 onKeyDown( const KeyEvent& event ) override; + + Uint32 onTextInput( const TextInputEvent& event ) override; + + void onWidgetFocusLoss() override; + + private: + String mNavigationInput; + bool mLoading{ false }; + bool mReplayingNavigation{ false }; }; } // namespace eefm diff --git a/src/tools/eefm/panemanager.cpp b/src/tools/eefm/panemanager.cpp index 8387a942b..ad728b689 100644 --- a/src/tools/eefm/panemanager.cpp +++ b/src/tools/eefm/panemanager.cpp @@ -20,7 +20,8 @@ const char* PaneLayout = R"xml( - + + @@ -592,6 +593,7 @@ void PaneManager::navigate( FileTab* tab, URI uri, bool recordHistory ) { tab->pane->filterInput->setText( "" ); } applyFilter( tab ); + tab->view->beginNavigation(); tab->model->setURI( uri ); updateTabTitle( tab ); if ( tab == currentTab( *tab->pane ) ) @@ -690,6 +692,8 @@ void PaneManager::updateChrome( Pane& pane ) { } void PaneManager::onTabStateChanged( FileTab* tab ) { + if ( tab->model->loadState() != VFSDirectoryModel::LoadState::Loading ) + tab->view->endNavigation(); if ( !tab->selectWhenListed.empty() ) { if ( auto row = tab->model->rowOf( tab->selectWhenListed ) ) { tab->view->setSelection( tab->model->index( *row ) );