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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **Two identical files in one folder are no longer both sent to the trash when one run renames or
moves them.** Each was taken for a copy of the other that was already in place, so both were
removed instead of moved, and the run could then fail and be undone with the two files missing
from the folder. A file the same run has still to move no longer counts as a copy already in
place, and renaming a file inside its own folder never removes it. A file removed this way went to
the system trash.
- **An organize run that did not move everything says so and stays on the Organize screen.** A
banner above the file list says how many files moved, how many did not and why, or that the whole
run was undone and the files are back where they were. Before, a run that failed part-way or was
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.
- **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
run no longer reports files as organized while nothing appears anywhere new.
- **A folder on an external or second drive can be a Smart Folder, can be scanned and can have
folders created in it.** These were refused for any folder outside your user folder, with a
message about a link or short name. Folders inside system folders, or reached through a link that
leads into one, are still refused. A folder that is not inside a Smart Folder or Documents and was
not picked in a file dialog is confirmed once in a prompt.
- **A folder the app will not scan, or cannot read, gives the reason.** Choosing or dropping such a
folder on the Discover screen said that no files were found.
- **An image that arrives just as a text model starts loading no longer starts the vision runtime
beside it.** The image looked for a loading model before the load began and found none. The vision
runtime now checks the memory budget again at the moment it starts; if a load began meanwhile, the
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ tree.
`ci`) enforces a monotonic floor and blocks new .js under `COVERED_DIRS` (`src/shared`,
`src/preload`, `src/main/{ipc,services,analysis,errors,utils,workers,transports,core}`,
`src/renderer/{services,hooks,phases,store,utils,components}` today (every `.js` under `src/` is
now opted in — 495/495 as of 2026-11-11, and `GLOBAL_DIRECTIVE_FLOOR` matches at 495) — see
now opted in — 496/496 as of 2026-11-12, and `GLOBAL_DIRECTIVE_FLOOR` matches at 496) — see
`scripts/verify-checkjs-ratchet.js` for the authoritative list) from landing without the
directive. When adding a file to a covered dir: put `// @ts-check` on line 1 and fix the errors
before commit. When opting in a new file elsewhere: bump `GLOBAL_DIRECTIVE_FLOOR` in
Expand Down
19 changes: 10 additions & 9 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,17 +292,18 @@ Then run a small batch first and review outcomes before scaling up.
- Review naming defaults in **Settings -> Default Locations**.
- Confirm convention/date/case/separator values.

### "That folder is inside a link that points outside your allowed locations."
### "That folder leads into a system folder through a link or short name."

You can see this when you use **Create directory** on a Smart Folder marked **Missing** in Setup. On
this computer, StratoSort creates folders only inside your home folder, Documents, Downloads or
Desktop, and never inside a system folder. It goes by where the folder really is, not by the path as
written. If the path runs through a shortcut-style link (a symbolic link, or a junction on Windows)
that leads somewhere outside those places, or into a system folder, the folder is refused.
You can see this when you use **Create directory** on a Smart Folder marked **Missing** in Setup.
StratoSort creates folders on any drive, an external one included, but never inside a system folder.
It goes by where the folder really is, not by the path as written. If the path runs through a
shortcut-style link (a symbolic link, or a junction on Windows) or a Windows short name that leads
into a system folder, the folder is refused.

- Edit the Smart Folder to use the real folder the link points to.
- If that real folder is outside those places, create it yourself in File Explorer or Finder, then
choose it as the Smart Folder's location with **Browse**.
- Edit the Smart Folder to use a folder outside the system folders.

A folder that is not inside one of your Smart Folders or Documents is created after you confirm it
in a prompt. If the path runs through a link, the prompt names the folder the link really leads to.

### Need to see logs

Expand Down
2 changes: 1 addition & 1 deletion scripts/verify-checkjs-ratchet.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const COVERED_DIRS = [
'src/renderer/utils',
'src/renderer/components'
];
const GLOBAL_DIRECTIVE_FLOOR = 495;
const GLOBAL_DIRECTIVE_FLOOR = 496;
// ────────────────────────────────────────────────────────────────────────

const DIRECTIVE_SCAN_LINES = 5;
Expand Down
84 changes: 64 additions & 20 deletions src/main/ipc/files/batchOrganizeHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,13 @@ async function handleBatchOrganize(params) {
}
const batchOperationTotal = batch.operations.length;
const limit = pLimit(effectiveConcurrency);
// Files this batch has still to move. The duplicate check must not
// take one of them for a copy that is already filed: it may be moved
// or renamed away later in this batch. Built here because each
// operation rewrites its paths to the validated form once it starts.
const pendingSources = batch.operations
.filter((op) => op.status !== 'done' && typeof op.source === 'string')
.map((op) => op.source);

// Abort reasons the USER deliberately triggered (renderer buttons).
// Anything else on the signal — 'ipc-timeout', 'shutdown' (abortAll),
Expand Down Expand Up @@ -814,7 +821,13 @@ async function handleBatchOrganize(params) {
const { signal } = abortController;
if (signal.aborted) startGrace();
else signal.addEventListener('abort', startGrace, { once: true });
const movePromise = performFileMove(op, log, computeFileChecksum, signal);
const movePromise = performFileMove(
op,
log,
computeFileChecksum,
signal,
pendingSources
);
try {
const outcome = await Promise.race([movePromise, abandoned]);
if (outcome !== MOVE_ABANDONED) return outcome;
Expand Down Expand Up @@ -909,17 +922,36 @@ async function handleBatchOrganize(params) {
}

if (moveResult && moveResult.skipped) {
skippedCount++;
const resolvedDestination = moveResult.destination || op.destination;
const currentFileName = path.basename(op.source);
results.push({
success: true,

// Verify state is consistent (dest exists, source gone) before
// anything reports the file as filed.
try {
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.
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.';
}
throw verifyError;
}

consecutivePermissionFailures = 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({
index: i,
source: op.source,
destination: resolvedDestination,
operation: op.type || 'move',
skipped: true,
reason: moveResult.reason
originalDestination: op._originalDestination,
rollbackAction: 'restore_duplicate_source'
});
renewBatchLock(batchId);

// One info line per skipped file in every build: the Live
// Activity feed shows "Skipped <name> (already in place)". File
Expand All @@ -932,20 +964,22 @@ async function handleBatchOrganize(params) {
destination: path.basename(resolvedDestination)
});

// Verify state is consistent (dest exists, source gone)
await verifyMoveCompletion(op.source, resolvedDestination, log);
// Rollback started while this skip was being verified: it
// restores the source and reports the row (see the same check
// on the move path below).
if (shouldRollback) return;

consecutivePermissionFailures = 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({
index: i,
skippedCount++;
results.push({
success: true,
source: op.source,
destination: resolvedDestination,
originalDestination: op._originalDestination,
rollbackAction: 'restore_duplicate_source'
operation: op.type || 'move',
skipped: true,
reason: moveResult.reason,
// 'trashed' or 'deleted': where the source went.
sourceRemoval: moveResult.sourceRemoval
});
renewBatchLock(batchId);

// Not awaited (see the success path below for why).
void processingState?.markOrganizeOpDone?.(batchId, i, {
Expand Down Expand Up @@ -1629,8 +1663,11 @@ function reconcileRolledBackResults(rollbackOutcome, completedOperations, rollba
* @param {LoggerLike} log
* @param {typeof computeFileChecksum} checksumFn
* @param {AbortSignal|null} [abortSignal]
* @param {string[]} [pendingSources] - Every source the caller's batch has
* still to move (the same array for each call), so the duplicate check does
* not count one of them as a copy already at the destination.
*/
async function performFileMove(op, log, checksumFn, abortSignal = null) {
async function performFileMove(op, log, checksumFn, abortSignal = null, pendingSources) {
// This prevents creating numbered copies of files that already exist.
const duplicateResult = await handleDuplicateMove({
sourcePath: op.source,
Expand All @@ -1639,7 +1676,8 @@ async function performFileMove(op, log, checksumFn, abortSignal = null) {
logger: log,
logPrefix: '[FILE-OPS]',
dedupContext: 'batchOrganize',
removeEmbeddings: removeEmbeddingsForPathBestEffort
removeEmbeddings: removeEmbeddingsForPathBestEffort,
pendingSources
});
if (duplicateResult) return duplicateResult;

Expand Down Expand Up @@ -2431,9 +2469,15 @@ async function previewBatchOrganize({ operation, log }) {
// PREVIEW_MAX_NUMERIC_ATTEMPTS so the preview stays snappy even when the
// destination directory is densely packed. The real executor still
// walks up to MAX_NUMERIC_RETRIES on collision before UUID-fallback.
//
// A file that is already at its destination (same path, or the same name
// in another case where the filesystem ignores case) occupies that path
// itself, so the executor's move does not collide with anything.
let finalDestination = op.destination;
let collision = false;
if (await pathExistsSilent(op.destination)) {
const alreadyAtDestination =
_canonicalizeForOverlapCheck(op.source) === _canonicalizeForOverlapCheck(op.destination);
if (!alreadyAtDestination && (await pathExistsSilent(op.destination))) {
collision = true;
conflicts += 1;
const ext = path.extname(op.destination);
Expand Down
Loading
Loading