eefm: add batch conflict handling and preserve rapid navigation input

Add asynchronous per-entry conflict resolution to VFS transfers. Let
eefm apply skip, rename, overwrite, and overwrite-older decisions across
a transfer batch, with separate choices for files and folder merges.
Preserve completed work and skipped move sources, and cancel the whole
batch when requested.

Reset type-to-select when navigating and buffer typing and Enter while
directory listings load. Discard queued input when focus leaves the pane.

Allow right-clicks on the empty pane status area to open the background
context menu.

Document the VFS API and add transfer regression coverage.
This commit is contained in:
Martín Lucas Golini
2026-10-21 01:34:25 -03:00
parent ca1873a65d
commit e910d72403
17 changed files with 1011 additions and 139 deletions
+5 -2
View File
@@ -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
+4 -1
View File
@@ -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.
+9 -6
View File
@@ -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:
+18
View File
@@ -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<ConflictResolution>`. 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;
@@ -6,11 +6,35 @@
#include <eepp/core/small_unique_function.hpp>
#include <eepp/vfs/config.hpp>
#include <eepp/vfs/error.hpp>
#include <eepp/vfs/fileinfo.hpp>
#include <functional>
#include <memory>
#include <optional>
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<VFSTask<ConflictResolution>( TransferConflict, CancellationToken )>;
struct EE_VFS_API TransferProgress {
URI currentSource;
URI currentDestination;
@@ -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<WatchHandle> watch( const URI& root, const WatchOptions& options = {} );
+271 -77
View File
@@ -1,5 +1,7 @@
#include <eepp/vfs/transferengine.hpp>
#include <chrono>
#include <eepp/system/filesystem.hpp>
#include <eepp/vfs/detail/filewriter.hpp>
#include <eepp/vfs/detail/transferstate.hpp>
#include <eepp/vfs/path.hpp>
@@ -11,8 +13,8 @@ using detail::await;
using detail::FileWriter;
Transfer TransferEngine::start( VirtualFileSystem& vfs, std::shared_ptr<ThreadPool> 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<detail::TransferState>();
Transfer transfer( state );
if ( !pool ) {
@@ -20,20 +22,23 @@ Transfer TransferEngine::start( VirtualFileSystem& vfs, std::shared_ptr<ThreadPo
Err( VFSError( VFSErrorCode::InvalidOperation, source, "VFS is shutting down" ) ) );
return transfer;
}
auto engine = std::make_shared<TransferEngine>( vfs, state, std::move( source ),
std::move( destination ), options, move );
auto engine =
std::make_shared<TransferEngine>( 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<detail::TransferState> 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<TransferResult> 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<TransferResult> 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<TransferResult> 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<void> TransferEngine::checkSource( const VFSFileInfo& info, const URI&
return Ok();
}
VFSResult<void> TransferEngine::checkDirectoryDestination() {
VFSResult<void> 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<std::string> missing;
while ( true ) {
auto resolved = await( mDstProvider->canonicalize( ancestor, mToken ) );
@@ -258,8 +308,8 @@ VFSResult<void> TransferEngine::scan( Node& node, const URI& uri, const URI& des
return Ok();
}
VFSResult<bool> TransferEngine::copyNode( const Node& node, const URI& source,
const URI& destination, int depth ) {
VFSResult<bool> 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<bool> 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<bool> TransferEngine::copyNode( const Node& node, const URI& source,
return Ok( true );
}
VFSResult<bool> TransferEngine::copyDirectory( const Node& node, const URI& source,
const URI& destination, int depth ) {
VFSResult<bool> 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<bool> TransferEngine::isSameFile( const URI& source, const URI& destin
return Ok( canonicalSource.value().getPath() == canonicalDestination.value().getPath() );
}
VFSResult<ConflictResolution> 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<URI> TransferEngine::renamedDestination( const URI& destination, const std::string& name,
bool directory ) {
auto validate = [this, directory]( URI renamed ) -> VFSResult<URI> {
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<void> 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<void> 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<Uint8> buffer( mOptions.chunkSize );
Uint64 offset = 0;
auto copied = FileWriter::replace(
*mDstProvider, target.value(), replace, mToken,
[&]( FileHandle& dst ) -> VFSResult<void> {
Uint64 offset = 0;
while ( true ) {
if ( auto c = checkCancelled( source ); !c )
return c;
@@ -453,12 +641,18 @@ VFSResult<void> TransferEngine::copyFile( const VFSFileInfo& info, const URI& so
return written;
}
offset += got;
mResult.bytesTransferred += got;
mState->updateProgress( [got]( TransferProgress& p ) { p.bytesDone += got; } );
}
},
[]() -> VFSResult<void> { 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;
}
@@ -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<System::ThreadPool> 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<detail::TransferState> 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<TransferResult> run();
@@ -47,16 +49,15 @@ class TransferEngine {
/** Includes aliases through existing destination ancestors, when canonicalization is supported.
*/
VFSResult<void> checkDirectoryDestination();
VFSResult<void> checkDirectoryDestination( const URI& sourceURI, const URI& destinationURI );
VFSResult<void> 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<bool> copyNode( const Node& node, const URI& source, const URI& destination,
int depth );
VFSResult<bool> copyNode( const Node& node, const URI& source, URI destination, int depth );
VFSResult<bool> copyDirectory( const Node& node, const URI& source, const URI& destination,
VFSResult<bool> 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<bool> isSameFile( const URI& source, const URI& destination );
VFSResult<void> copyFile( const VFSFileInfo& info, const URI& source, const URI& destination );
VFSResult<ConflictResolution> resolveConflict( const VFSFileInfo& sourceInfo,
const VFSFileInfo& destinationInfo,
const URI& source, const URI& destination );
VFSResult<URI> renamedDestination( const URI& destination, const std::string& name,
bool directory );
VFSResult<void> copyFile( const VFSFileInfo& info, const URI& source, const URI& destination,
bool overwrite );
VirtualFileSystem& mVfs;
std::shared_ptr<detail::TransferState> 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
@@ -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
+320
View File
@@ -568,6 +568,287 @@ UTEST( VFSTransfer, directoryMergeRequiresExplicitFileOverwrite ) {
EXPECT_STDSTREQ( "new", readString( vfs, file ) );
}
UTEST( VFSTransfer, resolvesNestedConflictsWithoutRestartingTheTree ) {
VirtualFileSystem vfs;
auto mem = std::make_shared<MemoryProvider>();
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<ConflictResolution>( 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<MemoryProvider>();
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>( 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<MemoryProvider>();
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>(
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<MemoryProvider>();
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>(
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<VFSFileInfo> stat( const URI& uri, const StatOptions& options,
CancellationToken token ) override {
return MemoryProvider::stat( uri, options, token )
.then( [time = uri.getLastPathSegment() == "source" ? 20 : destinationTime](
VFSResult<VFSFileInfo>&& result ) {
if ( result )
result.value().setModifiedTime( time );
return std::move( result );
} );
}
};
VirtualFileSystem vfs;
auto mem = std::make_shared<TimedProvider>();
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>(
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<MemoryProvider>();
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>( 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>(
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<void> 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<RacingProvider>();
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<VFSResult<ConflictResolution>> resolve;
auto pending = VFSTask<ConflictResolution>::create( resolve );
TransferConflictResolver onConflict = [&, first = true]( const TransferConflict&,
CancellationToken ) mutable {
if ( first ) {
first = false;
return std::move( pending );
}
return readyValue<ConflictResolution>( 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<MemoryProvider>();
vfs.registerProvider( mem );
mem->putFile( URI( "mem://test/source" ), "new" );
mem->putFile( URI( "mem://test/destination" ), "old" );
TaskResolver<VFSResult<ConflictResolution>> resolve;
auto pending = VFSTask<ConflictResolution>::create( resolve );
std::atomic<bool> 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<MemoryProvider>( "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>(
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>(
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;
+115
View File
@@ -1,6 +1,11 @@
#include "dialogs.hpp"
#include "location.hpp"
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/uilinearlayout.hpp>
#include <eepp/ui/uimessagebox.hpp>
#include <eepp/ui/uipushbutton.hpp>
#include <eepp/ui/uitextinput.hpp>
#include <eepp/vfs/path.hpp>
#include <memory>
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<void( EE::VFS::ConflictResolution, bool )> 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<ConflictResolution, bool>;
auto once = std::make_shared<OnceCallback<Answer>>();
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
+7
View File
@@ -1,7 +1,9 @@
#ifndef EEFM_DIALOGS_HPP
#define EEFM_DIALOGS_HPP
#include <eepp/ui/uimessagebox.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/vfs/transfer.hpp>
#include <functional>
#include <optional>
#include <string>
@@ -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<void( EE::VFS::ConflictResolution, bool )> done );
};
} // namespace eefm
+101 -41
View File
@@ -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<URI>& uris, const URI& destina
void FileOperations::transferFiles( const std::vector<URI>& sources, const URI& destination,
bool move, int paneIndex ) {
auto batch = std::make_shared<TransferBatch>();
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<URI>& 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<TransferBatch>();
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<TransferBatch>& 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<TransferBatch>& 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<VFSResult<ConflictResolution>> resolve;
auto task = VFSTask<ConflictResolution>::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<TransferResult>&& result ) {
.then( mApp.executor(), [this, id, destination, kind,
batch]( VFSResult<TransferResult>&& 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;
} );
}
+19 -4
View File
@@ -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<URI>& 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<std::pair<URI, URI>> entries;
size_t next{ 0 };
Uint64 conflictsSkipped{ 0 };
UIWindow* dialog{ nullptr };
std::optional<EE::VFS::ConflictAction> files;
std::optional<EE::VFS::ConflictAction> 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<TransferBatch> 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<TransferBatch>& batch );
void startTransfer( const URI& source, const URI& destination,
const std::shared_ptr<TransferBatch>& batch );
void updateTransfers();
+58
View File
@@ -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 ) ) &&
+14
View File
@@ -59,6 +59,11 @@ class FileTableView : public UITableView {
/** @return The row of a widget inside this view (cells or rows), if any. */
std::optional<size_t> 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
+5 -1
View File
@@ -20,7 +20,8 @@ const char* PaneLayout = R"xml(
<TabWidget class="pane_tabs" layout_width="match_parent" layout_height="0" layout_weight="1" />
<hbox class="pane_status_bar" layout_width="match_parent" layout_height="wrap_content">
<TextView class="pane_status" layout_width="wrap_content" layout_height="wrap_content" />
<widget layout_width="0" layout_weight="1" layout_height="1dp" />
<!-- Let clicks in the empty area reach the status bar background menu. -->
<widget layout_width="0" layout_weight="1" layout_height="1dp" enabled="false" />
<hbox class="pane_transfer" layout_width="wrap_content" layout_height="wrap_content" visible="false">
<TextView class="transfer_text" layout_gravity="center_vertical" />
<ProgressBar class="transfer_bar" layout_width="140dp" layout_gravity="center_vertical" display-percent="false" />
@@ -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 ) );