diff --git a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts index b69c9bf3bb..a62994ecaf 100644 --- a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts @@ -1204,6 +1204,12 @@ async function renderPage(options: { importResult?: | { ok: false; reason: 'commit_outcome_unknown' } | Promise<{ ok: false; reason: 'commit_outcome_unknown' }>; + /** + * Per-source answers for a batch: `ok` lands, `unknown` is the Host not + * answering, `throw` is a rejection. Keyed by source session id, because a + * batch is exactly the case where the ids must not share one answer. + */ + importBySource?: Record; onOpenImported?: (sessionId: string) => void; locale?: 'en' | 'zh'; }): Promise<{ @@ -1212,6 +1218,8 @@ async function renderPage(options: { listCalls(): number; listInputs(): Array<{ includeArchived: boolean }>; hostCalls(): Array<{ operation: 'listSources' | 'list' | 'import'; host?: DesktopRuntimeHostRef }>; + /** Source ids handed to `import`, in the order the batch walked them. */ + importedIds(): string[]; }> { const { document, window } = parseHTML('
'); const matchMedia = (media: string) => ({ @@ -1240,6 +1248,7 @@ async function renderPage(options: { IS_REACT_ACT_ENVIRONMENT: true, }); let listCalls = 0; + const importedIds: string[] = []; const listInputs: Array<{ includeArchived: boolean }> = []; const hostCalls: Array<{ operation: 'listSources' | 'list' | 'import'; @@ -1272,8 +1281,16 @@ async function renderPage(options: { if (result instanceof Error) throw result; return result; }, - import: async (_input: unknown, host?: DesktopRuntimeHostRef) => { + import: async (input: unknown, host?: DesktopRuntimeHostRef) => { hostCalls.push({ operation: 'import', host }); + const sourceSessionId = (input as { sourceSessionId?: string }).sourceSessionId ?? ''; + importedIds.push(sourceSessionId); + const perSource = options.importBySource?.[sourceSessionId]; + if (perSource === 'throw') throw new Error(`import-failed:${sourceSessionId}`); + if (perSource === 'unknown') return { ok: false, reason: 'commit_outcome_unknown' }; + if (perSource === 'ok') { + return { ok: true, session: { id: `imported-${sourceSessionId}` } }; + } return options.importResult ?? Promise.reject(new Error('import is not used by this test')); }, }, @@ -1305,6 +1322,7 @@ async function renderPage(options: { listCalls: () => listCalls, listInputs: () => listInputs, hostCalls: () => hostCalls, + importedIds: () => importedIds, }; } @@ -1341,3 +1359,193 @@ function segment(container: HTMLElement, value: string): HTMLButtonElement | und container.querySelectorAll('button[role="radio"]'), ).find((button) => button.getAttribute('data-value') === value); } + +/** + * Selecting several conversations and importing them in one go. + * + * The page is a directory you pick from, so the checkboxes are always there — + * there is no mode to enter. What these cases pin is the accounting: a batch + * that reports one number for four different outcomes is worse than no batch. + */ +describe('ImportTasksSettingsPage batch import', () => { + function rows(container: HTMLElement): HTMLInputElement[] { + return Array.from(container.querySelectorAll('li input[type="checkbox"]')); + } + + function masterBox(container: HTMLElement): HTMLInputElement { + const box = container.querySelector( + '.maka-import-selection-bar input[type="checkbox"]', + ); + assert.ok(box, 'master checkbox renders'); + return box; + } + + async function tick(box: HTMLInputElement, checked: boolean): Promise { + // React's checkbox onChange is driven by the native click, and its value + // tracker swallows a programmatic `.checked` write without one. + await act(async () => { + box.checked = checked; + box.dispatchEvent(new (globalThis.window as unknown as { Event: typeof Event }).Event('click', { + bubbles: true, + cancelable: true, + })); + await Promise.resolve(); + }); + } + + it('the master box marks and unmarks exactly the rows on screen', async () => { + const { container } = await renderPage({ + catalog: { + sessions: [ + externalSession({ id: 'a', name: 'A' }), + externalSession({ id: 'b', name: 'B' }), + ], + nextCursor: null, + }, + }); + + assert.equal(rows(container).length, 2); + await tick(masterBox(container), true); + assert.deepEqual(rows(container).map((box) => box.checked), [true, true]); + assert.match(container.textContent ?? '', /2 \/ 2 selected/); + + await tick(masterBox(container), false); + assert.deepEqual(rows(container).map((box) => box.checked), [false, false]); + assert.match(container.textContent ?? '', /0 \/ 2 selected/); + }); + + it('the master box reads indeterminate for a partial selection', async () => { + // The usual state during a selection, and the one a checked/unchecked pair + // cannot express. + const { container } = await renderPage({ + catalog: { + sessions: [externalSession({ id: 'a' }), externalSession({ id: 'b' })], + nextCursor: null, + }, + }); + + await tick(rows(container)[0]!, true); + assert.equal(masterBox(container).indeterminate, true); + await tick(rows(container)[1]!, true); + assert.equal(masterBox(container).indeterminate, false); + assert.equal(masterBox(container).checked, true); + }); + + it('imports the marked rows one at a time and counts each outcome once', async () => { + // Sequential on purpose: recovery re-reads the catalog window an attempt + // came from, so overlapping attempts would race that read, and a progress + // count is only true when one thing is happening. + const { container, importedIds } = await renderPage({ + catalog: { + sessions: [ + externalSession({ id: 'fresh', name: 'Fresh' }), + externalSession({ + id: 'again', + name: 'Again', + importState: { importedCount: 1, importedSessionIds: ['prior'], isImporting: false }, + }), + externalSession({ id: 'broken', name: 'Broken' }), + ], + nextCursor: null, + }, + importBySource: { fresh: 'ok', again: 'ok', broken: 'throw' }, + }); + + await tick(masterBox(container), true); + const run = buttonWithText(container, 'Import selected'); + assert.ok(run, 'the batch button renders'); + await act(async () => { + run.click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.deepEqual(importedIds(), ['fresh', 'again', 'broken']); + const text = container.textContent ?? ''; + // Two imported, and the summary says one of them now exists twice — + // re-importing is how a conversation is refreshed, but a user who marked + // three and reads "imported 2" deserves to know which kind they were. + assert.match(text, /Imported 2 conversations/); + assert.match(text, /1 of them had been imported before/); + // One rejection does not become the batch's answer for the rows after it. + assert.match(text, /1 more could not be imported/); + }); + + it('a Host that does not answer is not counted as a failure', async () => { + // Only a catalog read settles whether an unanswered conversion landed. + // Calling it a failure is what invites the retry that makes a second copy. + const { container } = await renderPage({ + catalog: { sessions: [externalSession({ id: 'quiet' })], nextCursor: null }, + importBySource: { quiet: 'unknown' }, + }); + + await tick(masterBox(container), true); + const run = buttonWithText(container, 'Import selected'); + assert.ok(run); + await act(async () => { + run.click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const text = container.textContent ?? ''; + assert.match(text, /No conversation was imported/); + assert.doesNotMatch(text, /could not be imported/); + // It surfaces through the unconfirmed banner, which owns the retry. + assert.match(text, /unconfirmed|Unconfirmed|outcome/i); + }); + + it('spins only the conversion in flight, not every queued row', async () => { + // A spinner claims something is happening now. Marking every selected row + // would put one on rows the batch has not reached, and on rows it already + // finished. + let releaseFirst: ((value: { ok: false; reason: 'commit_outcome_unknown' }) => void) | undefined; + const { container } = await renderPage({ + catalog: { + sessions: [externalSession({ id: 'a', name: 'A' }), externalSession({ id: 'b', name: 'B' })], + nextCursor: null, + }, + // The first conversion parks until released, so the assertion lands while + // exactly one row is converting and the other is queued. + importResult: new Promise((resolve) => { + releaseFirst = resolve; + }), + }); + + await tick(masterBox(container), true); + const run = buttonWithText(container, 'Import selected'); + assert.ok(run); + await act(async () => { + run.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const spinning = Array.from(container.querySelectorAll('li')).map((row) => + row.textContent?.includes('Importing') === true || !!row.querySelector('[aria-busy="true"]'), + ); + assert.equal(spinning.filter(Boolean).length, 1, 'exactly one row reads as converting'); + + await act(async () => { + releaseFirst?.({ ok: false, reason: 'commit_outcome_unknown' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + }); + + it('the batch button stays out of reach until something is marked', async () => { + const { container } = await renderPage({ + catalog: { sessions: [externalSession({ id: 'a' })], nextCursor: null }, + }); + + const run = buttonWithText(container, 'Import selected'); + assert.ok(run); + assert.equal(run.disabled, true); + await tick(rows(container)[0]!, true); + assert.equal(buttonWithText(container, 'Import selected')?.disabled, false); + }); +}); diff --git a/apps/desktop/src/renderer/locales/external-session-import-copy.ts b/apps/desktop/src/renderer/locales/external-session-import-copy.ts index f617144909..76e6265918 100644 --- a/apps/desktop/src/renderer/locales/external-session-import-copy.ts +++ b/apps/desktop/src/renderer/locales/external-session-import-copy.ts @@ -79,6 +79,18 @@ type ExternalSessionImportCopy = { * or paged away by the time it renders. */ importOutcomeUnknownDescription: (names: readonly string[]) => string; + selectAllAriaLabel: string; + /** Marked out of listed — the source's own total is not a number this page knows. */ + selectedCount: (selected: number, listed: number) => string; + selectRowAriaLabel: (name: string) => string; + importSelected: string; + /** Which one of how many the batch is on, so the count means something. */ + batchProgress: (done: number, total: number) => string; + batchDoneTitle: (imported: number) => string; + /** Counted apart from the total: these conversations now exist twice. */ + batchDuplicated: (count: number) => string; + batchFailed: (count: number) => string; + batchNothingImported: string; }; const COPY = { @@ -126,6 +138,15 @@ const COPY = { importNotRecordedTitle: '没有发现新任务', importNotRecordedDescription: '没有记录到新的任务,可以安全重试。', importOutcomeUnknownTitle: '需要确认导入结果', + selectAllAriaLabel: '全选或全不选', + selectedCount: (selected, listed) => `已选 ${selected} / ${listed}`, + selectRowAriaLabel: (name) => `选择 ${name}`, + importSelected: '导入所选', + batchProgress: (done, total) => `正在导入 ${done} / ${total}`, + batchDoneTitle: (imported) => `已导入 ${imported} 个对话`, + batchDuplicated: (count) => `其中 ${count} 个之前已导入过,现在各有两份。`, + batchFailed: (count) => `另有 ${count} 个没能导入。`, + batchNothingImported: '没有对话被导入。', importOutcomeUnknownDescription: (names) => `以下对话的导入结果无法确认:${names.map((name) => `「${name}」`).join('、')}。请先在任务列表中查找,已经出现的不要再次导入。`, }, @@ -171,6 +192,16 @@ const COPY = { importNotRecordedTitle: 'No new task found', importNotRecordedDescription: 'No new task was recorded, so it is safe to retry.', importOutcomeUnknownTitle: 'Check the import result', + selectAllAriaLabel: 'Select all or none', + selectedCount: (selected, listed) => `${selected} / ${listed} selected`, + selectRowAriaLabel: (name) => `Select ${name}`, + importSelected: 'Import selected', + batchProgress: (done, total) => `Importing ${done} / ${total}`, + batchDoneTitle: (imported) => `Imported ${imported} conversations`, + batchDuplicated: (count) => + `${count} of them had been imported before and now exist twice.`, + batchFailed: (count) => `${count} more could not be imported.`, + batchNothingImported: 'No conversation was imported.', importOutcomeUnknownDescription: (names) => `Maka could not confirm the outcome of these imports: ${names.map((name) => `“${name}”`).join(', ')}. Look in the task list first, and do not import again anything that is already there.`, }, diff --git a/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx index bd7740f55d..39fc454fd3 100644 --- a/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx @@ -32,7 +32,16 @@ import type { DesktopRuntimeHostRef, DesktopSessionSummary, } from '../../preload/bridge-contract.js'; -import { Spinner, useMountedRef, useUiLocale } from '@maka/ui'; +import { + EMPTY_LISTED_SELECTION, + listedSelectionMasterState, + pruneListedSelection, + setAllListedSelected, + Spinner, + toggleListedSelection, + useMountedRef, + useUiLocale, +} from '@maka/ui'; import { ICON_SIZE, MessageSquare } from '@maka/ui/icons'; import { getExternalSessionImportCopy } from '../locales/external-session-import-copy.js'; import { localizedShellErrorMessage } from '../locales/shell-copy.js'; @@ -146,6 +155,75 @@ type ImportAttempt = { loadedCatalogItemCountBefore: number; }; +/** + * The page's import activity. + * + * `single` is one row's 导入 button; `batch` is the selection's. They are one + * state because they are one activity and cannot overlap — and `summary` is + * what the last batch left behind, which no other slot on this page can hold. + * + * A batch runs SEQUENTIALLY, and not because the Host cannot take two: it + * dedupes per (adapter, source id) and is happy to convert different + * conversations at once. The reasons are here, on this page. Recovery re-reads + * the whole catalog window an attempt came from, so overlapping attempts would + * race that read; a progress count is only true when one thing is happening; + * and the page has no useful answer to "which of these five failed" if they + * fail together. + */ +type ImportRun = + | { kind: 'idle'; summary?: ImportBatchOutcome } + | { kind: 'single'; attempt: ImportAttempt } + /** `current` is the one conversion actually in flight; the rest are queued. */ + | { kind: 'batch'; done: number; total: number; current?: string }; + +const IDLE_IMPORT_RUN: ImportRun = { kind: 'idle' }; + +/** What one row of a batch did. */ +type ImportBatchDisposition = 'imported' | 'duplicated' | 'failed' | 'unknown'; + +/** + * What a batch import can honestly say afterwards. + * + * `duplicated` is counted apart from `imported` because a row that already had + * a copy is selectable on purpose — re-importing is how one is refreshed — but + * a user who marked twelve and reads "imported 12" deserves to know that two of + * them now exist twice. + * + * `unknown` is neither a success nor a failure: the call did not answer, and + * only a catalog read settles whether the conversion landed. Folding it into + * failures is what would invite the retry that makes a second copy. + */ +type ImportBatchOutcome = { + imported: number; + duplicated: number; + failed: readonly string[]; + unknown: readonly string[]; +}; + +const EMPTY_IMPORT_BATCH_OUTCOME: ImportBatchOutcome = { + imported: 0, + duplicated: 0, + failed: [], + unknown: [], +}; + +function recordImportBatchResult( + outcome: ImportBatchOutcome, + sourceSessionId: string, + disposition: ImportBatchDisposition, +): ImportBatchOutcome { + switch (disposition) { + case 'imported': + return { ...outcome, imported: outcome.imported + 1 }; + case 'duplicated': + return { ...outcome, imported: outcome.imported + 1, duplicated: outcome.duplicated + 1 }; + case 'failed': + return { ...outcome, failed: [...outcome.failed, sourceSessionId] }; + case 'unknown': + return { ...outcome, unknown: [...outcome.unknown, sourceSessionId] }; + } +} + type ImportRecovery = | { kind: 'landed'; attempt: ImportAttempt; importedSessionId: string } | { kind: 'not_recorded'; attempt: ImportAttempt }; @@ -199,17 +277,32 @@ export function ImportTasksSettingsPage(props: { const [searchDraft, setSearchDraft] = useState(''); const [search, setSearch] = useState(''); const [catalog, setCatalog] = useState(EMPTY_CATALOG); - const [sourceLoading, setSourceLoading] = useState(false); - const [sourceResolved, setSourceResolved] = useState(false); + /** + * One probe, three phases — not two booleans that are always written + * together. `sourceLoading && sourceResolved` was never a state this page + * could be in, and nothing enforced that. + */ + const [sourceProbe, setSourceProbe] = useState<'idle' | 'loading' | 'resolved'>('idle'); const [catalogLoading, setCatalogLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); /** - * At most one import at a time. Not because two conversions would collide — - * Desktop Main can take both — but because the first one to succeed calls - * `onImported`, which closes Settings and opens the new task, orphaning any - * other import on a page the user can no longer see. + * What import work this page is doing, and what the last run said. + * + * One state, not three. A single import and a batch are the same activity — + * a batch is a run of conversions the user asked for at once — and the + * summary is what that activity leaves behind. Keeping them apart invited + * combinations the page can never be in (a single import in flight while a + * batch runs) and cost the page hook budget it does not have: the renderer + * debt ledger refuses this file more stateful hooks than it already carries. + * + * Still at most one at a time, and still not because two conversions would + * collide — Desktop Main dedupes per (adapter, source id) and takes both + * happily. A single import ends by calling `onImported`, which closes + * Settings and opens the new task; anything else running would be orphaned on + * a page the user can no longer see. A batch stays here and reports instead, + * which is why it can hold several conversions where a single one cannot. */ - const [activeImport, setActiveImport] = useState(null); + const [importRun, setImportRun] = useState(IDLE_IMPORT_RUN); const [sourceError, setSourceError] = useState(null); const [catalogError, setCatalogError] = useState(null); const [importError, setImportError] = useState(null); @@ -223,6 +316,12 @@ export function ImportTasksSettingsPage(props: { * local lock and either exposes the landed task or allows a safe retry. */ const [uncertainImports, setUncertainImports] = useState([]); + /** + * Rows marked for a batch. Always available: this page exists to pick + * conversations out of a directory, so there is no mode to enter. + */ + const [selection, setSelection] = useState(EMPTY_LISTED_SELECTION); + // Only the newest list request may write. Switching source or toggling the // archived filter while a page is in flight would otherwise land the old // source's rows under the new source's label. @@ -263,8 +362,7 @@ export function ImportTasksSettingsPage(props: { const loadSources = useCallback(async () => { const generation = ++requestGeneration.current; - setSourceLoading(true); - setSourceResolved(false); + setSourceProbe('loading'); setSourceError(null); setCatalogError(null); setImportError(null); @@ -285,8 +383,7 @@ export function ImportTasksSettingsPage(props: { setSourceError(localizedShellErrorMessage(error, copy.loadFailedFallback, locale)); } finally { if (generation === requestGeneration.current) { - setSourceLoading(false); - setSourceResolved(true); + setSourceProbe('resolved'); } } }, [copy.loadFailedFallback, host, locale]); @@ -429,7 +526,7 @@ export function ImportTasksSettingsPage(props: { useEffect(() => { if ( adapterId === null || - activeImport !== null || + importRun.kind !== 'idle' || catalogLoading || loadingMore || !hasCatalogImportInFlight @@ -443,7 +540,7 @@ export function ImportTasksSettingsPage(props: { }, EXTERNAL_SESSION_IMPORT_POLL_MS); return () => clearTimeout(timeout); }, [ - activeImport, + importRun.kind, adapterId, catalog.sessions.length, catalogPollTick, @@ -463,6 +560,21 @@ export function ImportTasksSettingsPage(props: { [locale], ); + /** + * The page's only call into `externalSessions.import`. + * + * Both the row button and the batch go through here. That is a bridge-surface + * fact as much as a tidiness one: the renderer debt ledger counts call sites + * per file and forbids this page from gaining another, so a second literal + * call was never an option — and a single place to convert one conversation + * is what the two paths wanted anyway. + */ + const requestImport = useCallback( + (adapter: string, sourceSessionId: string) => + window.maka.externalSessions.import({ adapterId: adapter, sourceSessionId }, host), + [host], + ); + const recoverUnknownImport = useCallback( async (attempt: ImportAttempt) => { const generation = ++recoveryGeneration.current; @@ -557,7 +669,7 @@ export function ImportTasksSettingsPage(props: { const importConversation = useCallback( async (session: DesktopExternalSessionCatalogItem) => { - if (adapterId === null || activeImport !== null) return; + if (adapterId === null || importRun.kind !== 'idle') return; const attempt: ImportAttempt = { adapterId, sourceSessionId: session.id, @@ -571,14 +683,11 @@ export function ImportTasksSettingsPage(props: { latestImportedSessionIdBefore: session.importState.importedSessionIds[0], loadedCatalogItemCountBefore: catalog.sessions.length, }; - setActiveImport(attempt); + setImportRun({ kind: 'single', attempt }); setImportError(null); setImportRecovery(null); try { - const outcome = await window.maka.externalSessions.import({ - adapterId: attempt.adapterId, - sourceSessionId: attempt.sourceSessionId, - }, host); + const outcome = await requestImport(attempt.adapterId, attempt.sourceSessionId); // Navigating away from Settings unmounts this page while the import is // still in Desktop Main's hands. The conversion itself completes and is // stored either way; what must not happen is a completion from a page @@ -593,32 +702,147 @@ export function ImportTasksSettingsPage(props: { if (!mountedRef.current) return; setImportError(localizedShellErrorMessage(error, copy.importFailedFallback, locale)); } finally { - if (mountedRef.current) setActiveImport(null); + if (mountedRef.current) setImportRun(IDLE_IMPORT_RUN); } }, [ - activeImport, + importRun.kind, adapterId, catalog.sessions.length, copy.importFailedFallback, - host, locale, mountedRef, props, recoverUnknownImport, + requestImport, includeArchived, search, ], ); - const noSource = sourceResolved && !sourceLoading && !sourceError && adapterIds.length === 0; + const listedSourceIds = useMemo( + () => catalog.sessions.map((session) => session.id), + [catalog.sessions], + ); + /** + * The marked rows that are still on screen — derived, not reconciled. + * + * A selection outlives the list it was made from: a search narrows, the + * archived filter flips, a poll replaces the window. Intersecting at read + * time answers that without an effect, so a stale id can never be counted, + * confirmed, or imported even for the render between the catalog changing and + * an effect catching up. It also leaves this page's hook budget alone, which + * the renderer debt ledger will not let grow. + */ + const marked = useMemo( + () => pruneListedSelection(selection, listedSourceIds).selectedIds, + [listedSourceIds, selection], + ); + const masterState = listedSelectionMasterState({ selectedIds: marked }, listedSourceIds); + + const busy = importRun.kind !== 'idle'; + + const importSelected = useCallback(async () => { + if (adapterId === null || busy) return; + // Frozen at the press, in the catalog's own order rather than the set's + // insertion order, so the progress count walks the list the way the user + // reads it. + const targets = catalog.sessions.filter((session) => marked.has(session.id)); + if (targets.length === 0) return; + setImportError(null); + setImportRecovery(null); + setImportRun({ kind: 'batch', done: 0, total: targets.length, current: targets[0]?.id }); + let outcome = EMPTY_IMPORT_BATCH_OUTCOME; + try { + for (const [index, session] of targets.entries()) { + if (!mountedRef.current) return; + const attempt: ImportAttempt = { + adapterId, + sourceSessionId: session.id, + name: session.name, + includeArchived, + text: search, + importedCountBefore: session.importState.importedCount, + latestImportedSessionIdBefore: session.importState.importedSessionIds[0], + loadedCatalogItemCountBefore: catalog.sessions.length, + }; + // A row that already had a copy is selectable on purpose — re-importing + // is how a conversation is refreshed — but the summary owes the user + // the fact that it now exists twice. + const wasImported = session.importState.importedCount > 0; + try { + const result = await requestImport(attempt.adapterId, attempt.sourceSessionId); + if (!mountedRef.current) return; + outcome = recordImportBatchResult( + outcome, + session.id, + // Not `failed`: the call did not answer, and only a catalog read + // settles whether the conversion landed. Calling it a failure is + // what invites the retry that makes a second copy. + result.ok ? (wasImported ? 'duplicated' : 'imported') : 'unknown', + ); + if (!result.ok) { + // Recorded, not recovered. A single import recovers inline, but + // recovery re-reads the whole catalog window per attempt, and doing + // that between conversions would interleave N full reads with the + // batch and race the writes it is making. The unconfirmed banner + // names every one of these and its 重试 resolves them a press at a + // time, removing each as it settles. + setUncertainImports((current) => + current.some( + (entry) => + entry.adapterId === attempt.adapterId && + entry.sourceSessionId === attempt.sourceSessionId, + ) + ? current + : [...current, attempt], + ); + } + } catch { + if (!mountedRef.current) return; + // One rejection is not the batch's answer for every row after it. The + // failure is recorded and the run continues; the summary names how + // many did not go through. + outcome = recordImportBatchResult(outcome, session.id, 'failed'); + } + setImportRun({ + kind: 'batch', + done: index + 1, + total: targets.length, + current: targets[index + 1]?.id, + }); + } + } finally { + if (mountedRef.current) { + setImportRun({ kind: 'idle', summary: outcome }); + // Cleared because it was answered. Leaving the rows marked after a run + // invites a second press that would import each of them again. + setSelection(EMPTY_LISTED_SELECTION); + // Imported rows change their own description ("已导入 N 次"), and the + // page has no other reason to re-read. + void loadCatalog(adapterId); + } + } + }, [ + adapterId, + busy, + catalog.sessions, + includeArchived, + loadCatalog, + mountedRef, + requestImport, + search, + marked, + ]); + + const noSource = sourceProbe === 'resolved' && !sourceError && adapterIds.length === 0; const catalogEmpty = adapterId !== null && !catalogLoading && !catalogError && catalog.sessions.length === 0; // The shared normalizer decides what counts as a filter, so the empty-state // copy and the matcher cannot disagree about a whitespace-only box. const activeSearch = normalizeExternalSessionQueryText(search); - if (sourceLoading) { + if (sourceProbe === 'loading') { return (
@@ -731,12 +955,51 @@ export function ImportTasksSettingsPage(props: { free to change while an import runs: filter it out, switch source, retry a failed page, and the row is gone. This is also what tells the user why every remaining 导入 is disabled. */} - {activeImport !== null && ( + {importRun.kind === 'single' && ( +
+ +
+ )} + + {importRun.kind === 'batch' && (
+
+ )} + + {/* The batch stays on this page and reports here, rather than + navigating the way a single import does. There is no sensible task + to open after importing twelve, and leaving would strand the rows + that did not land. */} + {importRun.kind === 'idle' && importRun.summary !== undefined && ( +
+ 0 ? 'warning' : 'success'} + title={ + importRun.summary.imported > 0 + ? copy.batchDoneTitle(importRun.summary.imported) + : copy.batchNothingImported + } + description={ + [ + importRun.summary.duplicated > 0 + ? copy.batchDuplicated(importRun.summary.duplicated) + : null, + importRun.summary.failed.length > 0 + ? copy.batchFailed(importRun.summary.failed.length) + : null, + ] + .filter(Boolean) + .join(' ') || undefined + } />
)} @@ -815,6 +1078,37 @@ export function ImportTasksSettingsPage(props: { )} + {catalog.sessions.length > 0 && ( + + + setSelection(setAllListedSelected(listedSourceIds, checked)) + } + /> + + {copy.selectedCount(marked.size, listedSourceIds.length)} + + +