feat(torrent): complete lifecycle controls and diagnostics

- add durable Torrent telemetry, availability, sharing, relocation, and web-seed workflows\n- fence queue ownership, lifecycle recovery, persistence, and native control races\n- add localized UI, generated bindings, regression coverage, and smoke validation
This commit is contained in:
NimBold
2026-08-04 01:18:24 +03:30
parent 55a905df14
commit 579a8f7f80
33 changed files with 3505 additions and 139 deletions
+126
View File
@@ -0,0 +1,126 @@
import type { TorrentFile } from '../bindings/TorrentFile';
import type { TorrentFileSelectionEntry } from '../bindings/TorrentFileSelectionEntry';
import { normalizeTorrentWebSeedDrafts, type TorrentWebSeedDraft } from '../utils/downloads';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
type TorrentWebSeedFile = Pick<TorrentFile, 'index' | 'path'> | Pick<TorrentFileSelectionEntry, 'index' | 'relativePath'>;
type Props = {
files: readonly TorrentWebSeedFile[];
rows: readonly TorrentWebSeedDraft[];
onChange: (rows: TorrentWebSeedDraft[]) => void;
disabled?: boolean;
idPrefix: string;
};
const filePath = (file: TorrentWebSeedFile): string => 'path' in file ? file.path : file.relativePath;
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
const { t } = useTranslation();
const uriRefs = useRef<Array<HTMLInputElement | null>>([]);
const focusAfterRemoveRef = useRef<number | null>(null);
const filesForValidation = files.map(file => ({ index: file.index }));
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
useEffect(() => {
const rowIndex = focusAfterRemoveRef.current;
focusAfterRemoveRef.current = null;
if (rowIndex !== null) uriRefs.current[rowIndex]?.focus();
}, [rows]);
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
const updateRow = (rowIndex: number, update: Partial<TorrentWebSeedDraft>) => onChange(
rows.map((row, index) => index === rowIndex ? { ...row, ...update } : row)
);
const removeRow = (rowIndex: number) => {
const nextRows = rows.filter((_, index) => index !== rowIndex);
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : null;
onChange(nextRows);
};
return (
<div className="space-y-2">
{rows.length === 0 && (
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentWebSeedsEmpty)}</p>
)}
{rows.map((row, rowIndex) => {
const rowId = `${idPrefix}-${rowIndex}`;
const rowIsValid = normalizeTorrentWebSeedDrafts([row], filesForValidation) !== null;
return (
<div key={rowId} className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] gap-2 items-end">
<div className="min-w-0">
<label htmlFor={`${rowId}-file`} className="block text-[10px] text-text-muted mb-1">
{t($ => $.properties.torrentWebSeedsFile)}
</label>
{files.length === 1 ? (
<select
id={`${rowId}-file`}
value={files[0].index}
onChange={() => undefined}
disabled
aria-invalid={!rowIsValid}
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
>
<option value={files[0].index}>{files[0].index + 1}: {filePath(files[0])}</option>
</select>
) : (
<select
id={`${rowId}-file`}
value={row.fileIndex ?? ''}
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
disabled={disabled || files.length === 0}
aria-invalid={!rowIsValid}
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
>
<option value="" disabled>{t($ => $.properties.torrentWebSeedsFile)}</option>
{files.map(file => (
<option key={file.index} value={file.index}>
{file.index + 1}: {filePath(file)}
</option>
))}
</select>
)}
</div>
<div className="min-w-0">
<label htmlFor={`${rowId}-uri`} className="block text-[10px] text-text-muted mb-1">
{t($ => $.properties.torrentWebSeedsUri)}
</label>
<input
id={`${rowId}-uri`}
type="url"
ref={element => { uriRefs.current[rowIndex] = element; }}
value={row.uri}
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
disabled={disabled}
aria-invalid={!rowIsValid}
placeholder="https://mirror.example/torrent/"
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs font-mono disabled:opacity-50"
/>
</div>
<button
type="button"
onClick={() => removeRow(rowIndex)}
disabled={disabled}
aria-label={t($ => $.properties.torrentWebSeedsRemove)}
className="app-button min-h-[30px] px-2 text-xs disabled:opacity-50"
>
×
</button>
</div>
);
})}
{rows.length > 0 && !rowsAreValid && (
<p className="text-[11px] text-red-500" role="alert">
{t($ => $.properties.torrentWebSeedsInvalid)}
</p>
)}
<button
type="button"
onClick={addRow}
disabled={disabled || files.length === 0}
className="app-button px-3 text-xs disabled:opacity-50"
>
+ {t($ => $.properties.torrentWebSeedsAdd)}
</button>
</div>
);
};