mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-27 08:37:58 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {} );
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
} );
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 ) ) &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ) );
|
||||
|
||||
Reference in New Issue
Block a user