Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dbd2971
fix: match name collisions by name and type and batch their resolution
terrerox Sep 11, 2026
71f6dcc
fix: tag colliding existing items with isFolder so folders and files …
terrerox Sep 11, 2026
5a3ca29
fix: rename asExistingItems to addIsFolderField for clarity and consi…
terrerox Sep 20, 2026
4b0a43e
test: add e2e coverage for the skip option in name collisions
terrerox Sep 11, 2026
c947fa0
feat: skip item option in name collision
terrerox Sep 11, 2026
858fda9
feat: merge folder uploads into the existing folder on skip
terrerox Aug 26, 2026
8c4722c
test: add e2e coverage for name collision resolutions
terrerox Sep 11, 2026
b37601a
test: ensure collision dialog closes after resolving file upload
terrerox Sep 11, 2026
c66e58e
test: add e2e coverage for same-name restore collisions
terrerox Sep 20, 2026
8322c32
fix: resolve same-name items within a move collision batch
terrerox Sep 20, 2026
f9e798b
test: add e2e coverage for same-name restore collisions resolved one …
terrerox Sep 21, 2026
846a9a6
fix: restore same-name items resolved one at a time with replace
terrerox Sep 21, 2026
b733417
feat: add versioning max file size to name collision context and upda…
terrerox Oct 7, 2026
f549f92
feat: enhance unique name handling for file uploads to avoid collisions
terrerox Oct 7, 2026
8b4af77
Merge pull request #2160 from internxt/test/name-collision-same-name-…
terrerox Oct 7, 2026
c0be480
Merge pull request #2159 from internxt/fix/name-collision-same-name-b…
terrerox Oct 7, 2026
d6191a4
Merge pull request #2129 from internxt/feat/name-collision-folder-mer…
terrerox Oct 7, 2026
53ac5f2
Merge pull request #2150 from internxt/test/name-collision-skip-e2e
terrerox Oct 7, 2026
afbfe83
Merge pull request #2039 from internxt/feat/skip-item-option-in-name-…
terrerox Oct 7, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IRoot } from 'app/store/slices/storage/types';
import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors';
import { fileVersionsSelectors } from 'app/store/slices/fileVersions';
import { NameCollisionContext, resolveCollision } from './nameCollision.actions';
import { findExistingItemFor, findPendingGroupIndex, getRemainingGroups } from './nameCollision.utils';

const NameCollisionContainer: FC = () => {
const dispatch = useAppDispatch();
Expand All @@ -17,32 +18,82 @@ const NameCollisionContainer: FC = () => {
const operationType = collisionDialogInfo?.operation;
const newItems = useMemo(() => collisionGroups.flatMap((g) => g.duplicatedItems), [collisionGroups]);
const existingItems = useMemo(() => collisionGroups.flatMap((g) => g.existingItems), [collisionGroups]);
const remainingItemsCount = newItems.length;

const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace);
const limits = useAppSelector(fileVersionsSelectors.getLimits);
const maxUploadFileSize = useAppSelector(fileVersionsSelectors.getMaxFileSizeLimit);
const isVersioningEnabled = limits?.versioning?.enabled ?? false;
const versioningMaxFileSize = limits?.versioning?.maxFileSize ?? 0;

const context: NameCollisionContext = { dispatch, selectedWorkspace, maxUploadFileSize, isVersioningEnabled };
const context: NameCollisionContext = {
dispatch,
selectedWorkspace,
maxUploadFileSize,
isVersioningEnabled,
versioningMaxFileSize,
};

const closeDialog = () => {
dispatch(uiActions.setIsNameCollisionDialogOpen({ open: false, info: undefined }));
};

const triggerSelectedOptionsOnSubmit = async ({ operationType, operation }: OnSubmitPressed) => {
for (const group of collisionGroups) {
await resolveCollision(
{
operationType,
operation,
items: group.duplicatedItems,
existingItems: group.existingItems,
destinationUuid: group.destinationUuid,
},
context,
const triggerSelectedOptionsOnSubmit = async ({ operationType, operation, applyToAll }: OnSubmitPressed) => {
if (applyToAll) {
closeDialog();
await Promise.all(
collisionGroups.map((group) =>
resolveCollision(
{
operationType,
operation,
items: group.duplicatedItems,
existingItems: group.existingItems,
destinationUuid: group.destinationUuid,
},
context,
),
),
);
return;
}

const groupIndex = findPendingGroupIndex(collisionGroups);
const hasPendingGroup = groupIndex !== -1;
if (!hasPendingGroup) {
closeDialog();
return;
}

const group = collisionGroups[groupIndex];
const itemToUpload = group.duplicatedItems[0];
const collidingExistingItem = findExistingItemFor(itemToUpload, group.existingItems);
const isReplacing = operation === 'replace';
const replacedExistingItem = isReplacing ? collidingExistingItem : undefined;

await resolveCollision(
{
operationType,
operation,
items: [itemToUpload],
existingItems: group.existingItems,
destinationUuid: group.destinationUuid,
},
context,
);

const remainingGroups = getRemainingGroups(collisionGroups, groupIndex, replacedExistingItem);
const hasRemainingGroups = remainingGroups.length > 0;
if (hasRemainingGroups) {
dispatch(
uiActions.setIsNameCollisionDialogOpen({
open: true,
info: { groups: remainingGroups, operation: operationType },
}),
);
} else {
closeDialog();
}
closeDialog();
};

if (!collisionDialogInfo) return null;
Expand All @@ -56,6 +107,7 @@ const NameCollisionContainer: FC = () => {
onSubmitButtonPressed={triggerSelectedOptionsOnSubmit}
onCloseDialog={closeDialog}
operationType={operationType as 'move' | 'upload'}
remainingItemsCount={remainingItemsCount}
/>
);
};
Expand Down
82 changes: 56 additions & 26 deletions src/app/drive/components/NameCollisionDialog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FC, useEffect, useMemo, useState } from 'react';
import { Label, Radio, RadioGroup } from '@headlessui/react';

import { Button, Modal } from '@internxt/ui';
import { Button, Checkbox, Modal } from '@internxt/ui';
import { DriveItemData } from 'app/drive/types';
import { useTranslationContext } from 'app/i18n/provider/TranslationProvider';
import { IRoot } from 'app/store/slices/storage/types';
Expand All @@ -13,69 +13,85 @@ export const OPERATION_TYPE = {

export type OnSubmitPressed = {
operationType: 'move' | 'upload';
operation: 'keep' | 'replace';
operation: 'keep' | 'replace' | 'skip';
itemsToUpload: (File | IRoot | DriveItemData)[];
itemsToReplace: (DriveItemData | IRoot)[];
applyToAll: boolean;
};

export interface NameCollisionDialogProps {
operationType: 'upload' | 'move';
isOpen: boolean;
driveItems: (DriveItemData | IRoot)[];
newItems: (File | IRoot)[];
remainingItemsCount: number;
onCloseDialog(): void;
onCancelButtonPressed(): void;
onSubmitButtonPressed({ operationType, operation, itemsToUpload, itemsToReplace }: OnSubmitPressed): Promise<void>;
onSubmitButtonPressed({
operationType,
operation,
itemsToUpload,
itemsToReplace,
applyToAll,
}: OnSubmitPressed): Promise<void>;
}

const NameCollisionDialog: FC<NameCollisionDialogProps> = ({
isOpen,
operationType,
newItems,
driveItems,
remainingItemsCount,
onCancelButtonPressed,
onCloseDialog,
onSubmitButtonPressed,
}: NameCollisionDialogProps) => {
const { translate } = useTranslationContext();
const options = [
{
operation: 'replace' as const,
name: translate('modals.renameModal.replaceItem'),
},
{
operation: 'keep' as const,
name: translate('modals.renameModal.keepBoth'),
},
];
const options = useMemo(
() => [
{
operation: 'replace' as const,
name: translate('modals.alreadyExistsModal.replaceItem'),
},
{
operation: 'keep' as const,
name: translate('modals.alreadyExistsModal.keepBoth'),
},
{
operation: 'skip' as const,
name: translate('modals.alreadyExistsModal.skipItem'),
},
],
[translate],
);
const [isLoading, setIsLoading] = useState(false);
const [selectedOption, setSelectedOption] = useState(options[0]);
const [applyToAll, setApplyToAll] = useState(false);

const title = translate('modals.alreadyExistsModal.title');
const description = translate('modals.alreadyExistsModal.description', { itemName: newItems?.[0]?.name });

const title =
newItems?.length > 1 ? translate('modals.renameModal.titleMultipleItems') : translate('modals.renameModal.title');
const description =
newItems?.length > 1
? translate('modals.renameModal.multipleDescription')
: translate('modals.renameModal.description', { itemName: newItems?.[0]?.name });
const primaryButtonText = useMemo(
() =>
operationType === OPERATION_TYPE.MOVE
? translate('modals.renameModal.move')
: translate('modals.renameModal.upload'),
? translate('modals.alreadyExistsModal.move')
: translate('modals.alreadyExistsModal.upload'),
[operationType],
);

useEffect(() => {
if (isOpen) {
setIsLoading(false);
setSelectedOption(options[0]);
setApplyToAll(false);
}
}, [isOpen]);
}, [isOpen, options]);

const onClose = (): void => {
onCloseDialog();
onCancelButtonPressed();
setSelectedOption(options[0]);
setApplyToAll(false);
setIsLoading(false);
};

Expand All @@ -88,9 +104,10 @@ const NameCollisionDialog: FC<NameCollisionDialogProps> = ({
operation: selectedOption.operation,
itemsToUpload: newItems,
itemsToReplace: driveItems,
applyToAll,
});

onClose();
setIsLoading(false);
};

return (
Expand All @@ -100,7 +117,7 @@ const NameCollisionDialog: FC<NameCollisionDialogProps> = ({
<p className="text-base text-gray-80">{description}</p>

<RadioGroup value={selectedOption} onChange={setSelectedOption} disabled={isLoading}>
<Label className="sr-only">{translate('modals.renameModal.selectOption')}</Label>
<Label className="sr-only">{translate('modals.alreadyExistsModal.selectOption')}</Label>
<div className="flex flex-col items-start space-y-3">
{options.map((option) => (
<Radio value={option} className="rounded-md outline-none" key={option.operation}>
Expand All @@ -109,12 +126,14 @@ const NameCollisionDialog: FC<NameCollisionDialogProps> = ({
className={`flex h-5 w-5 flex-col items-center justify-center rounded-full ${
option.operation === selectedOption.operation
? 'bg-primary active:bg-primary-dark'
: 'border border-gray-40 bg-white group-hover:border-gray-50'
: 'border border-gray-40 bg-white dark:bg-transparent group-hover:border-gray-50'
}`}
>
<div
className={`h-2.5 w-2.5 rounded-full ${
option.operation === selectedOption.operation ? 'bg-white' : 'bg-white group-hover:bg-gray-5'
option.operation === selectedOption.operation
? 'bg-white'
: 'bg-white dark:bg-transparent group-hover:bg-gray-5'
}`}
/>
</div>
Expand All @@ -128,6 +147,17 @@ const NameCollisionDialog: FC<NameCollisionDialogProps> = ({
</div>
</RadioGroup>

{remainingItemsCount > 1 && (
<div className="flex items-center">
<Checkbox id="apply-to-all" checked={applyToAll} onClick={() => setApplyToAll((prev) => !prev)} />
<label
htmlFor="apply-to-all"
className="ml-2 cursor-pointer select-none text-base font-medium text-gray-80"
>
{translate('modals.alreadyExistsModal.applyToAll')}
</label>
</div>
)}
<div className="flex flex-row items-center justify-end space-x-2">
<Button disabled={isLoading} variant="secondary" onClick={onClose}>
{translate('actions.cancel')}
Expand Down
Loading
Loading