Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
294 lines
13 KiB
TypeScript
294 lines
13 KiB
TypeScript
/**
|
|
* SFTP Conflict Resolution Dialog
|
|
*/
|
|
|
|
import { AlertTriangle, GitMerge } from 'lucide-react';
|
|
import React, { memo, useEffect, useRef, useState } from 'react';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
|
|
import { Button } from '../ui/button';
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
|
import type { FileConflictAction } from '../../domain/models';
|
|
|
|
interface ConflictItem {
|
|
transferId: string;
|
|
fileName: string;
|
|
sourcePath: string;
|
|
targetPath: string;
|
|
isDirectory: boolean;
|
|
existingType?: 'file' | 'directory' | 'symlink';
|
|
applyToAllCount?: number;
|
|
existingSize: number;
|
|
newSize: number;
|
|
existingModified: number;
|
|
newModified: number;
|
|
}
|
|
|
|
export const canReplaceConflict = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): boolean => {
|
|
return canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
|
|
};
|
|
|
|
export const getSftpConflictDialogPresentation = (
|
|
conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>,
|
|
) => {
|
|
const isDestructiveDirectoryReplace = conflict.isDirectory && conflict.existingType === 'directory';
|
|
const descriptionKey = !conflict.isDirectory
|
|
? 'sftp.conflict.desc'
|
|
: conflict.existingType === 'file'
|
|
? 'sftp.conflict.folderFileDesc'
|
|
: conflict.existingType === 'symlink'
|
|
? 'sftp.conflict.folderSymlinkDesc'
|
|
: conflict.existingType === 'directory'
|
|
? 'sftp.conflict.folderDesc'
|
|
: 'sftp.conflict.folderUnknownDesc';
|
|
|
|
return {
|
|
titleKey: conflict.isDirectory ? 'sftp.conflict.folderTitle' : 'sftp.conflict.title',
|
|
descriptionKey,
|
|
showFileMetadata: !conflict.isDirectory,
|
|
showDirectoryReplaceWarning: isDestructiveDirectoryReplace,
|
|
mergeVariant: isDestructiveDirectoryReplace ? 'default' : 'outline',
|
|
replaceVariant: isDestructiveDirectoryReplace ? 'outline' : 'default',
|
|
} as const;
|
|
};
|
|
|
|
const getConflictTypeKey = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): string =>
|
|
getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
|
|
|
|
interface SftpConflictDialogProps {
|
|
conflicts: ConflictItem[];
|
|
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
|
|
formatFileSize: (size: number) => string;
|
|
}
|
|
|
|
interface ConflictFileSummaryProps {
|
|
title: string;
|
|
sizeLabel: string;
|
|
modifiedLabel: string;
|
|
size: string;
|
|
modified: string;
|
|
}
|
|
|
|
const ConflictFileSummary: React.FC<ConflictFileSummaryProps> = ({
|
|
title,
|
|
sizeLabel,
|
|
modifiedLabel,
|
|
size,
|
|
modified,
|
|
}) => (
|
|
<div className="rounded-md border border-border/60 bg-secondary/25 px-4 py-3">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div className="text-sm font-medium text-foreground">
|
|
{title}
|
|
</div>
|
|
</div>
|
|
<dl className="space-y-2 text-sm">
|
|
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
|
<dt className="text-muted-foreground">{sizeLabel}</dt>
|
|
<dd className="min-w-0 text-foreground">{size}</dd>
|
|
</div>
|
|
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
|
<dt className="text-muted-foreground">{modifiedLabel}</dt>
|
|
<dd className="min-w-0 break-words leading-relaxed text-foreground">{modified}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
);
|
|
|
|
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
|
|
const { t } = useI18n();
|
|
const [applyToAll, setApplyToAll] = useState(false);
|
|
const duplicateButtonRef = useRef<HTMLButtonElement>(null);
|
|
const mergeButtonRef = useRef<HTMLButtonElement>(null);
|
|
const replaceButtonRef = useRef<HTMLButtonElement>(null);
|
|
const previousConflictIdRef = useRef<string | undefined>(undefined);
|
|
const descriptionId = React.useId();
|
|
const directoryWarningId = React.useId();
|
|
const conflict = conflicts[0]; // Handle first conflict
|
|
const currentCanMerge = conflict?.isDirectory === true && conflict.existingType === 'directory';
|
|
const currentCanReplace = conflict ? canReplaceConflict(conflict) : false;
|
|
|
|
useEffect(() => {
|
|
const currentConflictId = conflict?.transferId;
|
|
const previousConflictId = previousConflictIdRef.current;
|
|
previousConflictIdRef.current = currentConflictId;
|
|
if (!currentConflictId || !previousConflictId || currentConflictId === previousConflictId) return;
|
|
|
|
const nextAction = currentCanMerge
|
|
? mergeButtonRef.current
|
|
: currentCanReplace
|
|
? replaceButtonRef.current
|
|
: duplicateButtonRef.current;
|
|
if (!nextAction) return;
|
|
|
|
// If the previously focused action disappears or becomes disabled,
|
|
// Radix may restore focus to the first button after this effect. Focus
|
|
// on the next frame so the current conflict's safe action wins.
|
|
if (typeof globalThis.requestAnimationFrame === 'function') {
|
|
const frame = globalThis.requestAnimationFrame(() => nextAction.focus());
|
|
return () => globalThis.cancelAnimationFrame(frame);
|
|
}
|
|
const timer = globalThis.setTimeout(() => nextAction.focus(), 0);
|
|
return () => globalThis.clearTimeout(timer);
|
|
}, [conflict?.transferId, currentCanMerge, currentCanReplace]);
|
|
|
|
if (!conflict) return null;
|
|
|
|
const formatDate = (timestamp: number) => {
|
|
return new Date(timestamp).toLocaleString();
|
|
};
|
|
|
|
const sameTypeConflictCount = Math.max(
|
|
conflict.applyToAllCount ?? 1,
|
|
conflicts.filter((item) => getConflictTypeKey(item) === getConflictTypeKey(conflict)).length,
|
|
);
|
|
const canMerge = currentCanMerge;
|
|
const canReplace = currentCanReplace;
|
|
const presentation = getSftpConflictDialogPresentation(conflict);
|
|
const showConflictDescription = !presentation.showDirectoryReplaceWarning;
|
|
const describedBy = presentation.showDirectoryReplaceWarning
|
|
? `${descriptionId} ${directoryWarningId}`
|
|
: descriptionId;
|
|
|
|
const handleAction = (action: FileConflictAction) => {
|
|
onResolve(conflict.transferId, action, applyToAll);
|
|
setApplyToAll(false);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
|
|
<DialogContent
|
|
className="gap-4 p-5 sm:max-w-[600px] sm:p-6"
|
|
aria-describedby={describedBy}
|
|
>
|
|
<DialogHeader className="space-y-1.5 pr-8">
|
|
<DialogTitle
|
|
className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-lg leading-tight"
|
|
aria-label={`${t(presentation.titleKey)}: ${conflict.fileName} ${t('sftp.conflict.alreadyExistsSuffix')}`}
|
|
>
|
|
<span className="min-w-0 break-words">{conflict.fileName}</span>
|
|
{' '}
|
|
<span className="text-base font-normal text-muted-foreground">
|
|
{t('sftp.conflict.alreadyExistsSuffix')}
|
|
</span>
|
|
</DialogTitle>
|
|
<div id={descriptionId} className={showConflictDescription ? undefined : 'sr-only'}>
|
|
<DialogDescription className="leading-5">
|
|
{t(presentation.descriptionKey)}
|
|
</DialogDescription>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3">
|
|
{presentation.showFileMetadata && (
|
|
<div className="space-y-3">
|
|
<ConflictFileSummary
|
|
title={t('sftp.conflict.existingFile')}
|
|
sizeLabel={t('sftp.conflict.size')}
|
|
modifiedLabel={t('sftp.conflict.modified')}
|
|
size={formatFileSize(conflict.existingSize)}
|
|
modified={formatDate(conflict.existingModified)}
|
|
/>
|
|
<ConflictFileSummary
|
|
title={t('sftp.conflict.newFile')}
|
|
sizeLabel={t('sftp.conflict.size')}
|
|
modifiedLabel={t('sftp.conflict.modified')}
|
|
size={formatFileSize(conflict.newSize)}
|
|
modified={formatDate(conflict.newModified)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{presentation.showDirectoryReplaceWarning && (
|
|
<div
|
|
id={directoryWarningId}
|
|
className="space-y-1.5 text-sm leading-5"
|
|
>
|
|
<div className="flex items-start gap-2 text-muted-foreground">
|
|
<GitMerge className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<p>{t('sftp.conflict.folderMergeHint')}</p>
|
|
</div>
|
|
<div className="flex items-start gap-2 text-sm font-normal text-destructive/90">
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<p>{t('sftp.conflict.folderReplaceWarning')}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sameTypeConflictCount > 1 && (
|
|
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={applyToAll}
|
|
onChange={(e) => setApplyToAll(e.target.checked)}
|
|
className="rounded border-border"
|
|
/>
|
|
{t('sftp.conflict.applyToAll', { count: sameTypeConflictCount })}
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="flex flex-col-reverse gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between sm:space-x-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleAction('stop')}
|
|
className="min-w-24 self-start border-border/70 text-muted-foreground hover:text-destructive"
|
|
>
|
|
{t('sftp.conflict.action.stop')}
|
|
</Button>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleAction('skip')}
|
|
className="min-w-24 border-border/70"
|
|
>
|
|
{t('sftp.conflict.action.skip')}
|
|
</Button>
|
|
<Button
|
|
ref={duplicateButtonRef}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleAction('duplicate')}
|
|
className="min-w-24 border-border/70"
|
|
>
|
|
{t('sftp.conflict.action.duplicate')}
|
|
</Button>
|
|
{conflict.isDirectory && (
|
|
<Button
|
|
ref={mergeButtonRef}
|
|
variant={presentation.mergeVariant}
|
|
size="sm"
|
|
onClick={() => handleAction('merge')}
|
|
disabled={!canMerge}
|
|
autoFocus={presentation.showDirectoryReplaceWarning}
|
|
className="min-w-24 border border-primary"
|
|
>
|
|
{t('sftp.conflict.action.merge')}
|
|
</Button>
|
|
)}
|
|
{canReplace && (
|
|
<Button
|
|
ref={replaceButtonRef}
|
|
variant={presentation.replaceVariant}
|
|
size="sm"
|
|
onClick={() => handleAction('replace')}
|
|
aria-describedby={presentation.showDirectoryReplaceWarning ? directoryWarningId : undefined}
|
|
className={presentation.showDirectoryReplaceWarning
|
|
? 'min-w-24 border-destructive/50 text-destructive hover:bg-destructive/10 hover:text-destructive'
|
|
: 'min-w-24'}
|
|
>
|
|
{t('sftp.conflict.action.replace')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export const SftpConflictDialog = memo(SftpConflictDialogInner);
|
|
SftpConflictDialog.displayName = 'SftpConflictDialog';
|