Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
undone could move on to the Complete screen as if it had worked. An identical copy that was
removed instead of moved is listed as a duplicate, with the copy that was kept, and is no longer
counted as a moved file.
- **One file that cannot be confirmed after its move no longer undoes the whole run.** After each
move the app checks that the file is at its destination and gone from its source. A file that
failed this check stopped the run and sent every file already moved back. It is now listed as not
moved, with whether it is still at its original location, and the other files stay moved. Three
such failures in a row, with no confirmed move between them, still undo the run.
- **Files that are already in the folder chosen for them are pointed out before the run.** When a
folder you sort from is also one of your Smart Folders, files matched to it stay where they are
(or are renamed there). A notice on the Organize screen and a mark in Preview now say which, so a
Expand Down
4 changes: 2 additions & 2 deletions docs/commercial-use-inventory.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-11-12T01:40:35.862Z",
"generatedAt": "2026-11-12T23:23:23.866Z",
"projectLicense": {
"riskLevel": "allowed",
"reason": "Project license is PolyForm Noncommercial 1.0.0 for personal/noncommercial use. Commercial rights are sold separately; third-party dependency obligations remain governed by their own licenses.",
Expand Down Expand Up @@ -6091,7 +6091,7 @@
},
{
"packageName": "@hono/node-server",
"version": "2.1.5",
"version": "2.1.6",
"license": "MIT",
"riskLevel": "allowed",
"directRuntime": false,
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 80 additions & 42 deletions src/main/ipc/files/batchOrganizeHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,31 @@ const pLimit = (concurrency) => {

// Use shared delay() from promiseUtils (supports .unref() for clean shutdown)

/**
* The two findings that only the move check reports: the file is not at its
* destination, or it is still at its source. See isEscalatingFileError for
* how the batch treats them.
*/
const MOVE_CHECK_DESTINATION_MISSING = 'MOVE_VERIFICATION_DESTINATION_FAILED';
const MOVE_CHECK_SOURCE_REMAINS = 'MOVE_VERIFICATION_SOURCE_EXISTS';

/**
* Where the file is when the move check does not find it at its destination,
* as a sentence that ends the failure message.
* @param {string} source
* @returns {Promise<string>}
*/
async function describeSourceAfterMissingDestination(source) {
try {
await fs.access(source);
return 'The file is still at its original location.';
} catch (error) {
return error.code === 'ENOENT'
? 'The file is no longer at its original location either.'
: 'Its original location could not be checked.';
}
}

/**
* Verify move operation completed correctly.
* Checks that destination exists and source is gone.
Expand All @@ -286,12 +311,15 @@ async function verifyMoveCompletion(source, destination, log) {
const isRetryable =
error.code === 'ENOENT' || (error.code && FILE_LOCK_ERROR_CODES.has(error.code));
if (!isRetryable || attempt === VERIFY_MAX_ATTEMPTS) {
const message =
error.code === 'ENOENT'
? `Move verification failed: destination does not exist after move: ${destination}`
: `Move verification failed: destination not accessible after move: ${destination}`;
const missing = error.code === 'ENOENT';
const message = missing
? `Move verification failed: destination does not exist after move: ${destination}. ${await describeSourceAfterMissingDestination(source)}`
: `Move verification failed: destination not accessible after move: ${destination}`;
const verificationError = /** @type {NodeJS.ErrnoException} */ (new Error(message));
verificationError.code = error.code || 'MOVE_VERIFICATION_DESTINATION_FAILED';
// A destination that cannot be read keeps the filesystem's code, so
// EIO still stops the batch and EACCES counts as a permission failure.
verificationError.code =
missing || !error.code ? MOVE_CHECK_DESTINATION_MISSING : error.code;
throw verificationError;
}
log.debug('[FILE-OPS] Destination not yet visible after move, retrying', {
Expand Down Expand Up @@ -321,7 +349,7 @@ async function verifyMoveCompletion(source, destination, log) {
`Move verification failed: source file still exists at original location: ${source}`
)
);
verificationError.code = 'MOVE_VERIFICATION_SOURCE_EXISTS';
verificationError.code = MOVE_CHECK_SOURCE_REMAINS;
throw verificationError;
}
log.debug('[FILE-OPS] Source still exists after move, retrying', {
Expand Down Expand Up @@ -512,9 +540,9 @@ async function handleBatchOrganize(params) {
// batchId already defined above for lock acquisition
let shouldRollback = false;
let rollbackReason = null;
// Permission failures since the last successful move; a run of them
// means permissions are gone for the batch, not for one file.
let consecutivePermissionFailures = 0;
// Permission failures and failed move checks since the last verified
// move; a run of them means the destination is at fault, not one file.
let consecutiveEscalatingFailures = 0;
const processedKeys = new Set(); // For idempotency
const mkdirPromises = new Map(); // Deduplicate mkdir across concurrent pLimit workers
let postBatchPersistence = { undoRecorded: true, pathIndexUpdated: true };
Expand Down Expand Up @@ -931,17 +959,16 @@ async function handleBatchOrganize(params) {
await verifyMoveCompletion(op.source, resolvedDestination, log);
} catch (verifyError) {
// The source was removed as a duplicate and the copy it
// duplicated cannot be confirmed. Rollback has nothing to
// copy back, so the message is where the user learns that the
// file is in the trash.
// duplicated cannot be confirmed. Nothing in the batch puts it
// back, so the message is where the user learns that the file
// is in the trash.
if (moveResult.sourceRemoval === 'trashed' && verifyError instanceof Error) {
verifyError.message +=
'. The original was moved to the system Trash / Recycle Bin as a duplicate and can be restored from there.';
verifyError.message = `${verifyError.message.replace(/\.?$/, '.')} The original was moved to the system Trash / Recycle Bin as a duplicate and can be restored from there.`;
}
throw verifyError;
}

consecutivePermissionFailures = 0;
consecutiveEscalatingFailures = 0;
// A duplicate skip still mutates disk state by deleting the source file.
// Track it so a later critical failure can restore the original path.
completedOperations.push({
Expand Down Expand Up @@ -1027,7 +1054,7 @@ async function handleBatchOrganize(params) {
// Post-move verification: ensure destination exists and source is gone
await verifyMoveCompletion(op.source, op.destination, log);

consecutivePermissionFailures = 0;
consecutiveEscalatingFailures = 0;
// Push to completedOperations IMMEDIATELY after verify so rollback
// can restore this file if another concurrent task triggers abort
completedOperations.push({
Expand Down Expand Up @@ -1094,11 +1121,12 @@ async function handleBatchOrganize(params) {

// Determine if critical error (always triggers rollback) or if
// strict mode makes any failure terminal. One permission denial
// is a per-file failure; a run of them is systemic.
// or failed move check is a per-file failure; a run of them is
// systemic.
let isCriticalError = isCriticalFileError(error);
if (isPermissionFileError(error)) {
consecutivePermissionFailures++;
if (consecutivePermissionFailures >= PERMISSION_FAILURE_ESCALATION_THRESHOLD) {
if (isEscalatingFileError(error)) {
consecutiveEscalatingFailures++;
if (consecutiveEscalatingFailures >= FAILURE_ESCALATION_THRESHOLD) {
isCriticalError = true;
}
}
Expand Down Expand Up @@ -1479,10 +1507,8 @@ async function handleBatchOrganize(params) {
* Grouped by root cause:
* - Read-only FS: EROFS
* - Storage/quota: ENOSPC, EDQUOT, EFBIG
* - IO integrity: EIO, checksum mismatch, verification failed
* - Path integrity: ENAMETOOLONG, EUUID_EXHAUSTED,
* MOVE_VERIFICATION_DESTINATION_FAILED,
* MOVE_VERIFICATION_SOURCE_EXISTS
* - IO integrity: EIO, checksum mismatch
* - Path integrity: ENAMETOOLONG, EUUID_EXHAUSTED
* - Resource pressure:EMFILE, ENFILE
* - Process lifecycle:ECANCELED
*
Expand All @@ -1502,11 +1528,18 @@ async function handleBatchOrganize(params) {
* are FILE_LOCK_ERROR_CODES above: a Windows AV/indexer hold, a
* delete-pending handle, or one source folder the user cannot write
* surfaces as EPERM/EACCES for that one file, and rolling back every other
* completed move for it discarded the whole organize. A run of
* PERMISSION_FAILURE_ESCALATION_THRESHOLD permission failures with no
* successful move in between is still treated as systemic (permissions
* revoked on the destination) and rolls the batch back; see
* isPermissionFileError and its use in handleBatchOrganize.
* completed move for it discarded the whole organize.
*
* NOTE: a failed move check (MOVE_VERIFICATION_DESTINATION_FAILED,
* MOVE_VERIFICATION_SOURCE_EXISTS) is NOT critical on its own either. The
* file that failed it never joins the completed moves, so a rollback would
* not bring it back; it only undid every move that had been confirmed. The
* failed row says where the file is.
*
* A run of FAILURE_ESCALATION_THRESHOLD of these two kinds of failure with no
* verified move in between is still treated as systemic (permissions revoked
* on the destination, or a destination that is losing files) and rolls the
* batch back; see isEscalatingFileError and its use in handleBatchOrganize.
*/
const CRITICAL_ERROR_CODES = new Set([
'EROFS',
Expand All @@ -1516,8 +1549,6 @@ const CRITICAL_ERROR_CODES = new Set([
'EIO',
'ENAMETOOLONG',
'EUUID_EXHAUSTED',
'MOVE_VERIFICATION_DESTINATION_FAILED',
'MOVE_VERIFICATION_SOURCE_EXISTS',
'EMFILE',
'ENFILE',
'ECANCELED'
Expand All @@ -1534,32 +1565,39 @@ const CRITICAL_MAPPED_ERROR_CODES = new Set([
'TOO_MANY_OPEN_FILES'
]);

const CRITICAL_MESSAGE_FRAGMENTS = ['checksum mismatch', 'verification failed'];
const CRITICAL_MESSAGE_FRAGMENTS = ['checksum mismatch'];

const PERMISSION_ERROR_CODES = new Set([
/**
* Codes that are a per-file failure once and systemic in a run: a permission
* or access denial, and the move check's two findings.
*/
const ESCALATING_ERROR_CODES = new Set([
'EACCES',
'EPERM',
'FILE_ACCESS_DENIED',
'PERMISSION_DENIED'
'PERMISSION_DENIED',
MOVE_CHECK_DESTINATION_MISSING,
MOVE_CHECK_SOURCE_REMAINS
]);

/**
* Permission failures in a row (no successful move in between) after which
* the batch treats them as systemic and rolls back, as it does for ENOSPC.
* Escalating failures in a row (no verified move in between) after which the
* batch treats them as systemic and rolls back, as it does for ENOSPC.
*/
const PERMISSION_FAILURE_ESCALATION_THRESHOLD = 3;
const FAILURE_ESCALATION_THRESHOLD = 3;

/**
* Whether a failed op's error is a permission/access denial. On its own that
* is a per-file failure (see CRITICAL_ERROR_CODES); repeated, it escalates.
* Whether a failed op's error is one of ESCALATING_ERROR_CODES. On its own
* that is a per-file failure (see CRITICAL_ERROR_CODES); repeated, it
* escalates.
* @param {any} error
* @returns {boolean}
*/
function isPermissionFileError(error) {
function isEscalatingFileError(error) {
if (!error || typeof error !== 'object') return false;
if (typeof error.code === 'string' && PERMISSION_ERROR_CODES.has(error.code)) return true;
if (typeof error.code === 'string' && ESCALATING_ERROR_CODES.has(error.code)) return true;
const originalCode = error.metadata?.originalCode;
return typeof originalCode === 'string' && PERMISSION_ERROR_CODES.has(originalCode);
return typeof originalCode === 'string' && ESCALATING_ERROR_CODES.has(originalCode);
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/utils/errorMapping.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ const FILE_OPERATION_CODE_MAP = {
message:
'That folder leads into a system folder through a link or short name. Choose a different folder.',
useHandlerMessage: true
},
// Organize (batchOrganizeHandler.js): the move ran but the check after it
// did not find the file where it should be. The handler's message names the
// path and says where the file is, so it is shown as is.
MOVE_VERIFICATION_DESTINATION_FAILED: {
severity: 'error',
message: 'The move could not be confirmed: the file was not found at its destination.',
useHandlerMessage: true
},
MOVE_VERIFICATION_SOURCE_EXISTS: {
severity: 'error',
message: 'The move could not be confirmed: the file is still at its original location.',
useHandlerMessage: true
}
};

Expand Down
Loading
Loading