Compare commits

...
Author SHA1 Message Date
bincxz 07d1a6f2c1 fix(sftp): focus the safe conflict action on open 2026-09-25 15:24:09 +08:00
2 changed files with 82 additions and 7 deletions

No files matched your search

@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import {
createDomRenderer,
dispatchDomEvent,
flushEffects,
installDomEnvironment,
} from "../test-support/renderReactDom.tsx";
const cases = [
{ name: "file", isDirectory: false, existingType: "file", action: "replace", label: "Replace" },
{ name: "folder", isDirectory: true, existingType: "directory", action: "merge", label: "Merge" },
{ name: "folder with a file target", isDirectory: true, existingType: "file", action: "duplicate", label: "Duplicate" },
{ name: "folder with unknown target type", isDirectory: true, existingType: undefined, action: "duplicate", label: "Duplicate" },
{ name: "legacy file with unknown target type", isDirectory: false, existingType: undefined, action: "duplicate", label: "Duplicate" },
] as const;
for (const scenario of cases) {
test(`initial ${scenario.name} conflict focuses ${scenario.label} and runs that action`, async (t) => {
const env = installDomEnvironment();
const previousMutationObserver = globalThis.MutationObserver;
const previousNodeFilter = globalThis.NodeFilter;
const previousHTMLInputElement = globalThis.HTMLInputElement;
Object.defineProperty(globalThis, "MutationObserver", { configurable: true, writable: true, value: env.window.MutationObserver });
Object.defineProperty(globalThis, "NodeFilter", { configurable: true, writable: true, value: env.window.NodeFilter });
Object.defineProperty(globalThis, "HTMLInputElement", { configurable: true, writable: true, value: env.window.HTMLInputElement });
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { SftpConflictDialog } = await import("./SftpConflictDialog.tsx");
const renderer = await createDomRenderer(env.document);
const resolved: Array<{ id: string; action: string }> = [];
t.after(async () => {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 20));
Object.defineProperty(globalThis, "MutationObserver", { configurable: true, writable: true, value: previousMutationObserver });
Object.defineProperty(globalThis, "NodeFilter", { configurable: true, writable: true, value: previousNodeFilter });
Object.defineProperty(globalThis, "HTMLInputElement", { configurable: true, writable: true, value: previousHTMLInputElement });
env.cleanup();
});
await renderer.render(React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(SftpConflictDialog, {
conflicts: [{
transferId: scenario.name,
fileName: "test.txt",
sourcePath: "/src/test.txt",
targetPath: "/dst/test.txt",
isDirectory: scenario.isDirectory,
existingType: scenario.existingType,
existingSize: 1,
newSize: 2,
existingModified: 1,
newModified: 2,
}],
onResolve: (id, action) => resolved.push({ id, action }),
formatFileSize: (size: number) => `${size} B`,
}),
));
await flushEffects();
await new Promise((resolve) => setTimeout(resolve, 20));
const focused = env.document.activeElement;
assert.equal(focused?.textContent, scenario.label);
assert.ok(focused, "a conflict action should receive focus");
await dispatchDomEvent(focused, new env.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(resolved, [{ id: scenario.name, action: scenario.action }]);
});
}
+11 -7
View File
@@ -107,6 +107,11 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
const conflict = conflicts[0]; // Handle first conflict
const currentCanMerge = conflict?.isDirectory === true && conflict.existingType === 'directory';
const currentCanReplace = conflict ? canReplaceConflict(conflict) : false;
const preferredActionRef = currentCanMerge
? mergeButtonRef
: currentCanReplace && conflict?.existingType
? replaceButtonRef
: duplicateButtonRef;
useEffect(() => {
const currentConflictId = conflict?.transferId;
@@ -114,11 +119,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
previousConflictIdRef.current = currentConflictId;
if (!currentConflictId || !previousConflictId || currentConflictId === previousConflictId) return;
const nextAction = currentCanMerge
? mergeButtonRef.current
: currentCanReplace
? replaceButtonRef.current
: duplicateButtonRef.current;
const nextAction = preferredActionRef.current;
if (!nextAction) return;
// If the previously focused action disappears or becomes disabled,
@@ -130,7 +131,7 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
}
const timer = globalThis.setTimeout(() => nextAction.focus(), 0);
return () => globalThis.clearTimeout(timer);
}, [conflict?.transferId, currentCanMerge, currentCanReplace]);
}, [conflict?.transferId, preferredActionRef]);
if (!conflict) return null;
@@ -160,6 +161,10 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
<DialogContent
className="gap-4 p-5 sm:max-w-[600px] sm:p-6"
aria-describedby={describedBy}
onOpenAutoFocus={(event) => {
event.preventDefault();
preferredActionRef.current?.focus();
}}
>
<DialogHeader className="space-y-1.5 pr-8">
<DialogTitle
@@ -262,7 +267,6 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
size="sm"
onClick={() => handleAction('merge')}
disabled={!canMerge}
autoFocus={presentation.showDirectoryReplaceWarning}
className="min-w-24 border border-primary"
>
{t('sftp.conflict.action.merge')}