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
26 changes: 26 additions & 0 deletions .changeset/answer-all-duplicate-prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'frontend': patch
---

Answer every duplicate prompt at once instead of one file at a time

Dropping ten files onto ten that already exist asked ten identical questions,
one after another, each needing its own click. Nothing said how many were left,
so there was no way to tell whether answering meant one more click or nine.

The dialog now offers to let the answer stand for every collision left in the
drop, so ten files take one click. It is honoured by the loop that raises the
prompts, in use-upload-dispatch, by not asking again. That loop awaits each
answer before the next question exists, so there is never a queue of prompts to
answer in bulk - which is the shape this looked like it had from the store.

The dialog also shows how many are left, which is most of what made repeating
the same answer feel endless.

Cancel used to close the dialog and resolve nothing, leaving the loop awaiting
an answer that never came: every file behind the cancelled one was never asked
about and never uploaded, and the drop stalled there in silence. Cancel is an
answer now. With more than one collision it reads "Skip this one", and a "Cancel
all" beside it abandons the rest of the drop. A file left alone this way gets a
notice saying so rather than the "could not find a free name" one, which was
about a different thing entirely.
20 changes: 20 additions & 0 deletions .changeset/duplicate-policy-setting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'frontend': patch
---

Let the answer to "this file already exists" be decided once, in settings

The prompt can now be answered in advance. General settings has a "When a file
already exists" choice: ask, keep both, or replace. It defaults to ask, which is
the behaviour there has always been, because deciding to overwrite by default is
the user's call rather than something to inherit from an install.

It is the same mechanism the prompt's own "do the same for the rest" uses. A
policy simply seeds that standing answer before the first question is asked, so
a drop of ten colliding files finishes without a single prompt.

Replacing without being asked leaves a notice on the transfers card saying how
many files were overwritten, and the setting says so beside the choice. A file
that was there is gone, and a setting chosen weeks earlier is not something
anyone remembers at the moment it acts. Keeping both takes nothing away and the
new name is already on the card, so it passes without one.
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,17 @@ export const SidebarCreateButton: React.FC<SidebarCreateButtonProps> = ({ onClic
{duplicateDialog.isOpen && (
<DuplicateDialog
isOpen={duplicateDialog.isOpen}
onClose={hideDuplicateDialog}
duplicateItem={{
name: duplicateDialog.folderName,
type: 'folder',
}}
onReplace={duplicateDialog.onReplace || (() => {})}
onKeepBoth={duplicateDialog.onKeepBoth || (() => {})}
// One folder, so there is never a rest of the drop to apply an answer
// to and the bulk controls stay hidden. Both handlers close the dialog
// themselves once the folder is created.
onResolve={(choice) =>
choice === 'replace' ? duplicateDialog.onReplace?.() : duplicateDialog.onKeepBoth?.()
}
onCancel={hideDuplicateDialog}
/>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { GeneralSettings } from '../types';
import { START_PAGE_OPTIONS, BULK_SHARE_DURATION_OPTIONS, isValidDuration } from '../constants';
import { RadioGroup } from './radio-group';
import { CustomDropdown } from '@/shared/components/ui/custom-dropdown';
import { UploadModeSettings } from '@/features/upload';
import { DuplicatePolicySettings, UploadModeSettings } from '@/features/upload';

interface GeneralSettingsPanelProps {
settings: GeneralSettings;
Expand Down Expand Up @@ -61,6 +61,19 @@ export function GeneralSettingsPanel({ settings, onUpdate }: GeneralSettingsPane
</div>
</div>

<div className="space-y-6">
<div className="border-b border-border pb-4">
<h3 className="text-lg font-medium text-foreground">When a file already exists</h3>
<p className="mt-1 text-sm text-muted-foreground">
What to do when something you upload has the same name as a file already there. The
prompt can still answer for a whole drop at once.
</p>
</div>
<div className="pl-0">
<DuplicatePolicySettings />
</div>
</div>

<div className="space-y-6">
<div className="border-b border-border pb-4">
<h3 className="text-lg font-medium text-foreground">Bulk file share duration</h3>
Expand Down
102 changes: 87 additions & 15 deletions frontend/src/features/upload/components/duplicate-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { File, Folder, AlertTriangle } from 'lucide-react';
import {
Dialog,
Expand All @@ -18,32 +18,52 @@ export interface DuplicateItem {

interface DuplicateDialogProps {
isOpen: boolean;
onClose: () => void;
onReplace: () => void;
onKeepBoth: () => void;
duplicateItem: DuplicateItem | null;
/**
* How many collisions are left in this drop, this one included. Only one is
* ever asked at a time, so this is what the rest of the drop looks like
* rather than a count of open dialogs.
*/
pendingCount?: number;
/** `applyToAll` asks for this answer to stand for every collision left. */
onResolve: (choice: 'replace' | 'keepBoth', applyToAll: boolean) => void;
onCancel: (applyToAll: boolean) => void;
}

export function DuplicateDialog({
isOpen,
onClose,
onReplace,
onKeepBoth,
duplicateItem,
pendingCount = 1,
onResolve,
onCancel,
}: DuplicateDialogProps) {
const [selectedAction, setSelectedAction] = useState<'replace' | 'keep-both'>('keep-both');
const [applyToAll, setApplyToAll] = useState(false);

/** Only worth offering when there is something else to apply the answer to. */
const hasQueue = pendingCount > 1;
const others = pendingCount - 1;

/**
* Cleared by hand, because the dialog is never unmounted between prompts:
* answering one reveals the next while it stays open. Only on the way back
* open, which happens between drops rather than between files, so ticking the
* box for one batch cannot carry into the next.
*/
useEffect(() => {
if (isOpen) setApplyToAll(false);
}, [isOpen]);

const handleUpload = () => {
if (selectedAction === 'keep-both') {
onKeepBoth();
} else {
onReplace();
}
onClose();
onResolve(selectedAction === 'keep-both' ? 'keepBoth' : 'replace', applyToAll);
};

const handleCancel = () => {
onClose();
onCancel(false);
};

const handleCancelAll = () => {
onCancel(true);
};

if (!duplicateItem) return null;
Expand All @@ -68,6 +88,17 @@ export function DuplicateDialog({
<DialogTitle className="text-lg font-medium" style={{ color: 'var(--foreground)' }}>
{duplicateItem.type === 'file' ? 'File already exists' : 'Folder already exists'}
</DialogTitle>
{/* Without this there was no way to tell whether answering meant
one more click or nine, which is most of what made repeating
the same answer feel endless. */}
{hasQueue && (
<span
className="ml-auto shrink-0 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: 'var(--muted)', color: 'var(--muted-foreground)' }}
>
{pendingCount} left
</span>
)}
</div>
<DialogDescription className="text-sm" style={{ color: 'var(--muted-foreground)' }}>
A {duplicateItem.type} named "{duplicateItem.name}" already exists in this location.
Expand Down Expand Up @@ -168,6 +199,27 @@ export function DuplicateDialog({
</div>
</label>
</div>

{hasQueue && (
<label
className="mt-4 flex items-center gap-3 rounded-lg p-3 cursor-pointer transition-colors"
style={{
backgroundColor: applyToAll ? 'var(--accent)' : 'transparent',
border: applyToAll ? '1px solid var(--primary)' : '1px solid var(--border)',
}}
>
<input
type="checkbox"
checked={applyToAll}
onChange={(e) => setApplyToAll(e.target.checked)}
className="h-4 w-4"
style={{ accentColor: 'var(--primary)' }}
/>
<span className="text-sm" style={{ color: 'var(--foreground)' }}>
Do the same for the other {others} {others === 1 ? 'item' : 'items'}
</span>
</label>
)}
</div>

<div
Expand All @@ -191,8 +243,28 @@ export function DuplicateDialog({
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
Cancel
{/* "Cancel" was only ever skipping this one file, which was not
obvious while nine more waited behind it. */}
{hasQueue ? 'Skip this one' : 'Cancel'}
</button>
{hasQueue && (
<button
onClick={handleCancelAll}
className="px-4 py-2 cursor-pointer text-sm font-medium transition-colors rounded-md"
style={{
color: 'var(--muted-foreground)',
backgroundColor: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
Cancel all
</button>
)}
<button
onClick={handleUpload}
className="px-6 py-2 text-sm cursor-pointer font-medium rounded-md transition-colors"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use client';

import React from 'react';
import { useUploadSettingsStore } from '@/features/upload/stores/use-upload-settings-store';
import { DUPLICATE_POLICIES, DuplicatePolicy } from '@/features/upload/types';
import { HelpCircle, Copy, Replace } from 'lucide-react';
import { CustomRadioSelect, RadioOption } from '@/shared/components/custom-radio-select';

const ICONS: Record<DuplicatePolicy, React.ComponentType<{ className?: string }>> = {
ask: HelpCircle,
keepBoth: Copy,
replace: Replace,
};

export const DuplicatePolicySettings: React.FC = () => {
const { duplicatePolicy, setDuplicatePolicy } = useUploadSettingsStore();

const options: RadioOption<DuplicatePolicy>[] = Object.values(DUPLICATE_POLICIES).map(
(config) => ({
value: config.policy,
label: config.label,
description: config.description,
icon: ICONS[config.policy],
tags:
config.policy === 'replace'
? [{ label: 'Overwrites files', variant: 'warning' as const }]
: [],
})
);

return (
<div className="space-y-4">
<CustomRadioSelect
options={options}
value={duplicatePolicy}
onChange={setDuplicatePolicy}
name="duplicate-policy"
/>

{duplicatePolicy === 'replace' && (
// Worth saying beside the choice rather than only at the moment it
// acts. A setting chosen once is not something anyone remembers weeks
// later, watching files be overwritten without being asked.
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
Files with the same name will be overwritten without asking. Each drop that replaces
something leaves a notice on the transfers card saying what happened.
</p>
</div>
)}
</div>
);
};
10 changes: 6 additions & 4 deletions frontend/src/features/upload/components/operations-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ export const OperationsModal: React.FC = () => {
const updateUpload = useUploadStore((state) => state.updateUpload);
const duplicateQueue = useUploadStore((state) => state.duplicateQueue);
const resolveDuplicate = useUploadStore((state) => state.resolveDuplicate);
const hideDuplicateDialog = useUploadStore((state) => state.hideDuplicateDialog);

// Only the oldest pending duplicate is shown; answering it reveals the next.
const currentDuplicate = duplicateQueue[0] ?? null;
Expand Down Expand Up @@ -847,10 +846,13 @@ export const OperationsModal: React.FC = () => {
{/* Duplicate Dialog */}
<DuplicateDialog
isOpen={currentDuplicate !== null}
onClose={hideDuplicateDialog}
duplicateItem={currentDuplicate?.duplicateItem ?? null}
onReplace={() => resolveDuplicate('replace')}
onKeepBoth={() => resolveDuplicate('keepBoth')}
onResolve={(choice, applyToAll) => resolveDuplicate(choice, applyToAll)}
onCancel={(applyToAll) => resolveDuplicate('cancel', applyToAll)}
// Off the prompt, not the queue: the queue holds one question at a
// time, and what the reader needs to know is how many files are behind
// this one in the drop being processed.
pendingCount={currentDuplicate?.remaining ?? 1}
/>
</>
);
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/features/upload/components/queue-notices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ import { useUploadQueueStore, type QueueNotice } from '../stores/use-upload-queu
const ACCENT: Record<QueueNotice['kind'], string> = {
renamed: 'var(--primary)',
skipped: 'var(--muted-foreground)',
// Amber like 'unverified': something was done that cannot be undone, and the
// reader did not ask for it in the moment.
replaced: '#f59e0b',
unverified: '#f59e0b',
};

const LABEL: Record<QueueNotice['kind'], string> = {
renamed: 'Renamed',
skipped: 'Skipped',
replaced: 'Replaced',
unverified: 'Not verified',
};

Expand Down
Loading
Loading