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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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.
- **A move to another drive that loses its temporary copy no longer says the original was not
found.** Such a move copies the file to a temporary name in the destination folder and renames it
into place. When another program removed or held that copy, the failure read "File not found" with
the path of the original, which was still there. It now names the destination folder, says the
temporary copy was removed or could not be read or renamed, and says the original was not moved.
Three of these in a row, with no confirmed move between them, undo the run, as failed move checks
do.
- **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-12T23:23:23.866Z",
"generatedAt": "2026-11-13T01:20:54.483Z",
"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 @@ -15113,7 +15113,7 @@
},
{
"packageName": "hono",
"version": "4.13.14",
"version": "4.13.17",
"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.

27 changes: 27 additions & 0 deletions src/main/errors/FileSystemError.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const FILE_SYSTEM_ERROR_CODES = {
ATOMIC_OPERATION_FAILED: 'ATOMIC_OPERATION_FAILED',
ROLLBACK_FAILED: 'ROLLBACK_FAILED',
TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT',
// A move between drives lost the temporary copy it makes in the destination
// folder (removed, or held, by something else) before renaming it into
// place. The source has not been touched at that point.
STAGED_COPY_FAILED: 'STAGED_COPY_FAILED',

// I/O errors
IO_ERROR: 'IO_ERROR',
Expand Down Expand Up @@ -171,6 +175,7 @@ class FileSystemError extends Error {
[FILE_SYSTEM_ERROR_CODES.ATOMIC_OPERATION_FAILED]: `Atomic operation failed: ${this.metadata.operation || 'unknown'}`,
[FILE_SYSTEM_ERROR_CODES.ROLLBACK_FAILED]: 'Failed to rollback file operation',
[FILE_SYSTEM_ERROR_CODES.TRANSACTION_TIMEOUT]: 'File operation transaction timed out',
[FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED]: this.describeStagedCopyFailure(),
[FILE_SYSTEM_ERROR_CODES.IO_ERROR]: `I/O error occurred: ${this.metadata.path || 'unknown'}`,
[FILE_SYSTEM_ERROR_CODES.NETWORK_ERROR]: `Network error accessing file: ${this.metadata.path || 'unknown'}`,
[FILE_SYSTEM_ERROR_CODES.UNKNOWN_ERROR]: `Unknown file system error: ${this.metadata.originalError || 'unknown'}`
Expand All @@ -179,6 +184,21 @@ class FileSystemError extends Error {
return messages[this.code] || `File system error: ${this.code}`;
}

/**
* Message for STAGED_COPY_FAILED. `metadata.path` is the temporary copy, so
* `metadata.directory` is the destination folder; `metadata.source` is the
* file being moved, which is still where it was.
*/
describeStagedCopyFailure() {
const { directory, originalCode, source } = /** @type {Record<string, any>} */ (this.metadata);
const where = directory ? `in ${directory}` : 'in the destination folder';
const what =
originalCode === 'ENOENT'
? 'was removed before the move finished'
: `could not be read or renamed${originalCode ? ` (${originalCode})` : ''}`;
return `The temporary copy ${where} ${what}. The original was not moved: ${source || 'unknown'}`;
}

/**
* Get user-friendly error message suitable for display in UI
*/
Expand Down Expand Up @@ -244,6 +264,8 @@ class FileSystemError extends Error {
'Failed to undo the file operation. Some files may need manual recovery.',
[FILE_SYSTEM_ERROR_CODES.TRANSACTION_TIMEOUT]:
'The operation took too long and was cancelled.',
[FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED]:
'The copy made in the destination folder was removed or blocked before the move finished. The original file was not moved.',
[FILE_SYSTEM_ERROR_CODES.IO_ERROR]:
'A hardware or disk error occurred. Check your disk health.',
[FILE_SYSTEM_ERROR_CODES.NETWORK_ERROR]:
Expand Down Expand Up @@ -299,6 +321,11 @@ class FileSystemError extends Error {
'Check disk for errors',
'Verify the source file is not corrupted'
],
[FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED]: [
'Try the move again',
'Check whether a sync client or security tool removes or locks new files in the destination folder',
'Choose a destination folder that other programs do not manage'
],
[FILE_SYSTEM_ERROR_CODES.IO_ERROR]: [
'Check your disk health',
'Run disk check (chkdsk)',
Expand Down
13 changes: 10 additions & 3 deletions src/main/ipc/files/batchOrganizeHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -1536,7 +1536,12 @@ async function handleBatchOrganize(params) {
* 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
* NOTE: STAGED_COPY_FAILED (a move between drives whose temporary copy in the
* destination folder was removed or held before it was renamed into place) is
* the same finding made one step earlier: the destination lost a file. The
* source was not touched, so it is a per-file failure too.
*
* A run of FAILURE_ESCALATION_THRESHOLD of these 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.
Expand Down Expand Up @@ -1569,15 +1574,17 @@ const CRITICAL_MESSAGE_FRAGMENTS = ['checksum mismatch'];

/**
* 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.
* or access denial, the move check's two findings, and a cross-drive move's
* lost temporary copy.
*/
const ESCALATING_ERROR_CODES = new Set([
'EACCES',
'EPERM',
'FILE_ACCESS_DENIED',
'PERMISSION_DENIED',
MOVE_CHECK_DESTINATION_MISSING,
MOVE_CHECK_SOURCE_REMAINS
MOVE_CHECK_SOURCE_REMAINS,
'STAGED_COPY_FAILED'
]);

/**
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/utils/errorMapping.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ const FILE_OPERATION_CODE_MAP = {
severity: 'error',
message: 'The move could not be confirmed: the file is still at its original location.',
useHandlerMessage: true
},
// A move between drives (atomicFileOperations.js crossDeviceMove): the
// temporary copy in the destination folder was removed or held before it
// could be renamed into place. The message names that folder and the
// original, which was not moved, so it is shown as is.
STAGED_COPY_FAILED: {
severity: 'error',
message:
'The copy made in the destination folder was removed or blocked before the move finished. The original file was not moved.',
useHandlerMessage: true
}
};

Expand Down
29 changes: 24 additions & 5 deletions src/shared/atomicFileOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ try {
SIZE_MISMATCH: 'SIZE_MISMATCH',
CHECKSUM_MISMATCH: 'CHECKSUM_MISMATCH',
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
WRITE_FAILED: 'WRITE_FAILED'
WRITE_FAILED: 'WRITE_FAILED',
STAGED_COPY_FAILED: 'STAGED_COPY_FAILED'
};
}

Expand Down Expand Up @@ -1805,7 +1806,9 @@ AtomicFileOperations.prototype.safeWriteFile = function safeWriteFile(filePath,
* nothing more is published or deleted: the source stays, the staging copy
* is removed and the call rejects with an AbortError.
* @returns {Promise<void>} Resolves when move is complete
* @throws {Error} If copy fails or verification fails
* @throws {Error} If copy fails or verification fails. A failure on the
* staging copy is a FileSystemError with code STAGED_COPY_FAILED whose
* message says the source was not moved.
*/
async function crossDeviceMove(source, dest, options = {}) {
const normalizedSource = resolveFsPath(source);
Expand Down Expand Up @@ -1955,10 +1958,26 @@ async function crossDeviceMove(source, dest, options = {}) {
if (error.isFileSystemError || error?.name === 'AbortError') {
throw error;
}
// EEXIST can only come from the publish onto the destination name, so
// name the occupied destination, not the source, in the error.
// Name the file the failure is about. Node reports the path a failed call
// was given, unchanged, as `error.path`. A failure on the staging copy
// (removed or held by a sync client or scanner between the copy and the
// publish) must not read as "source not found": the source is deleted
// only after the publish, so it is still in place.
if (error?.path === stagingPath) {
throw new FileSystemError(FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED, {
path: stagingPath,
source: normalizedSource,
destination: normalizedDest,
operation: 'crossDeviceMove',
originalError: error.message,
originalCode: error.code
});
}
// The destination name: EEXIST can only come from the publish onto it,
// and a failed probe of it (renameNoReplace) carries it as `error.path`.
const aboutDestination = error?.code === 'EEXIST' || error?.path === normalizedDest;
const fsError = FileSystemError.fromNodeError(error, {
path: error?.code === 'EEXIST' ? normalizedDest : normalizedSource,
path: aboutDestination ? normalizedDest : normalizedSource,
operation: 'crossDeviceMove',
destination: normalizedDest
});
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ const FILE_SYSTEM_ERROR_CODES = {
ATOMIC_OPERATION_FAILED: 'ATOMIC_OPERATION_FAILED',
ROLLBACK_FAILED: 'ROLLBACK_FAILED',
TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT',
STAGED_COPY_FAILED: 'STAGED_COPY_FAILED',
IO_ERROR: 'IO_ERROR',
NETWORK_ERROR: 'NETWORK_ERROR',
UNKNOWN_ERROR: 'UNKNOWN_ERROR'
Expand Down
29 changes: 29 additions & 0 deletions test/FileSystemError.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,35 @@ describe('FileSystemError', () => {
});
});

describe('STAGED_COPY_FAILED (the temporary copy of a move between drives)', () => {
const stagedCopy = (/** @type {Record<string, any>} */ metadata) =>
new FileSystemError(FILE_SYSTEM_ERROR_CODES.STAGED_COPY_FAILED, {
path: '/dest/Invoices/a.pdf.stratosort-1-abcd.tmp',
source: '/home/user/Downloads/a.pdf',
...metadata
});

test('a removed copy names the destination folder and the original', () => {
expect(stagedCopy({ originalCode: 'ENOENT' }).message).toBe(
'The temporary copy in /dest/Invoices was removed before the move finished. The original was not moved: /home/user/Downloads/a.pdf'
);
});

test('any other failure names its code instead of calling the copy removed', () => {
expect(stagedCopy({ originalCode: 'EBUSY' }).message).toBe(
'The temporary copy in /dest/Invoices could not be read or renamed (EBUSY). The original was not moved: /home/user/Downloads/a.pdf'
);
});

test('does not read as a missing file', () => {
const error = stagedCopy({ originalCode: 'ENOENT' });

expect(error.message).not.toMatch(/not found/i);
expect(error.getUserFriendlyMessage()).toContain('The original file was not moved');
expect(error.getActionableSteps()[0]).toBe('Try the move again');
});
});

describe('isRecoverable', () => {
test('returns false for corrupted file', () => {
const error = new FileSystemError(FILE_SYSTEM_ERROR_CODES.CORRUPTED_FILE, {});
Expand Down
Loading
Loading