From 0f609de844c0cbc48e7fb53396a90d5f32776c2b Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 03:00:11 +0800 Subject: [PATCH 1/2] feat: make destructive operations honest --- src-tauri/src/commands/history.rs | 4 +- src-tauri/src/services/history.rs | 65 +++++++++- src/api/commandClient.dispatch.test.ts | 23 ++++ src/components/ui/ConfirmDialog.test.tsx | 114 +++++++++++++++++- src/components/ui/ConfirmDialog.tsx | 105 ++++++++++++---- src/components/ui/Dialog.tsx | 8 +- src/features/history/StorageCleanupCard.tsx | 54 ++++++++- .../history/storageCleanupProblems.test.ts | 30 +++++ .../history/storageCleanupProblems.ts | 53 ++++++++ src/features/history/useRunDetailPage.ts | 56 ++++++--- src/features/history/useStorageCleanup.ts | 72 +++++++---- src/features/install/InstallConfirmModal.tsx | 1 + .../install/ResetBepinexConfirmModal.tsx | 15 ++- .../install/ResetDataConfirmModal.tsx | 21 +++- src/features/install/useInstallPage.ts | 30 ++++- .../shared/confirmedOperation.test.ts | 96 +++++++++++++++ src/features/shared/confirmedOperation.ts | 94 +++++++++++++++ src/i18n/messages.ts | 35 ++++++ src/pages/Install.tsx | 104 +++++++++++----- src/pages/RunDetail.tsx | 97 ++++++++------- 20 files changed, 917 insertions(+), 160 deletions(-) create mode 100644 src/features/history/storageCleanupProblems.test.ts create mode 100644 src/features/history/storageCleanupProblems.ts create mode 100644 src/features/shared/confirmedOperation.test.ts create mode 100644 src/features/shared/confirmedOperation.ts diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 826a199f..00178250 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -64,7 +64,7 @@ pub fn preview_storage_cleanup( app: tauri::AppHandle, scope: StorageCleanupScope, preset: StorageCleanupPreset, -) -> Result { +) -> Result { history::preview_storage_cleanup(&app, scope, preset) } @@ -74,6 +74,6 @@ pub fn execute_storage_cleanup( app: tauri::AppHandle, scope: StorageCleanupScope, preset: StorageCleanupPreset, -) -> Result { +) -> Result { history::execute_storage_cleanup(&app, scope, preset) } diff --git a/src-tauri/src/services/history.rs b/src-tauri/src/services/history.rs index 050d9cce..b37e6b70 100644 --- a/src-tauri/src/services/history.rs +++ b/src-tauri/src/services/history.rs @@ -255,6 +255,24 @@ impl History { } } + fn preview_cleanup_for_page( + &self, + scope: StorageCleanupScope, + preset: StorageCleanupPreset, + ) -> Result { + self.preview_cleanup(scope, preset) + .map_err(|diagnostic| history_action_problem("preview_storage_cleanup", diagnostic)) + } + + fn execute_cleanup_for_page( + &self, + scope: StorageCleanupScope, + preset: StorageCleanupPreset, + ) -> Result { + self.execute_cleanup(scope, preset) + .map_err(|diagnostic| history_action_problem("execute_storage_cleanup", diagnostic)) + } + fn require_database_exists(&self) -> Result<(), String> { self.paths .database_path @@ -320,16 +338,18 @@ pub fn preview_storage_cleanup( app: &tauri::AppHandle, scope: StorageCleanupScope, preset: StorageCleanupPreset, -) -> Result { - History::resolve(app)?.preview_cleanup(scope, preset) +) -> Result { + History::from_resolved_game_path_for_page(History::resolved_game_path(app))? + .preview_cleanup_for_page(scope, preset) } pub fn execute_storage_cleanup( app: &tauri::AppHandle, scope: StorageCleanupScope, preset: StorageCleanupPreset, -) -> Result { - History::resolve(app)?.execute_cleanup(scope, preset) +) -> Result { + History::from_resolved_game_path_for_page(History::resolved_game_path(app))? + .execute_cleanup_for_page(scope, preset) } fn history_paths_for_game_path(game_path: PathBuf) -> HistoryStorage { @@ -601,6 +621,43 @@ mod tests { } } + #[test] + fn storage_cleanup_page_classifies_preview_and_execute_failures() { + let temp = tempfile::tempdir().unwrap(); + let game_path = temp.path().join("The Bazaar"); + let history = History::from_resolved_game_path_for_page(Some(game_path.clone())).unwrap(); + std::fs::create_dir_all(history.paths.database_path.parent().unwrap()).unwrap(); + std::fs::write(&history.paths.database_path, b"not sqlite").unwrap(); + + for (operation, problem) in [ + ( + "preview_storage_cleanup", + history + .preview_cleanup_for_page( + StorageCleanupScope::RunData, + StorageCleanupPreset::All, + ) + .unwrap_err(), + ), + ( + "execute_storage_cleanup", + history + .execute_cleanup_for_page( + StorageCleanupScope::RunData, + StorageCleanupPreset::All, + ) + .unwrap_err(), + ), + ] { + assert_eq!(problem.code, SemanticProblemCode::HistoryActionFailed); + assert_eq!( + problem.params.get("operation").map(String::as_str), + Some(operation) + ); + assert!(problem.diagnostic.is_some()); + } + } + #[test] fn video_file_exists_accepts_existing_file_and_rejects_missing_file() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/api/commandClient.dispatch.test.ts b/src/api/commandClient.dispatch.test.ts index 4093872a..21a82e02 100644 --- a/src/api/commandClient.dispatch.test.ts +++ b/src/api/commandClient.dispatch.test.ts @@ -93,6 +93,29 @@ describe('native command adapter', () => { problem }); }); + + it('preserves cleanup operation failures for localized retry', async () => { + vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + const problem = { + code: 'history_action_failed', + params: { operation: 'execute_storage_cleanup' }, + diagnostic: 'database is locked' + }; + invokeMock.mockRejectedValueOnce(problem); + const { commandClient } = await import('./commandClient'); + + await expect( + commandClient.executeStorageCleanup('run_data', 'all') + ).rejects.toMatchObject({ + name: 'SemanticProblemError', + message: 'history_action_failed', + problem + }); + expect(invokeMock).toHaveBeenCalledWith('execute_storage_cleanup', { + scope: 'run_data', + preset: 'all' + }); + }); }); describe('normalizeBackendError sentinel contract', () => { diff --git a/src/components/ui/ConfirmDialog.test.tsx b/src/components/ui/ConfirmDialog.test.tsx index 95d9999a..44bd581c 100644 --- a/src/components/ui/ConfirmDialog.test.tsx +++ b/src/components/ui/ConfirmDialog.test.tsx @@ -1,8 +1,13 @@ import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { LocaleProvider } from '../../i18n/LocaleProvider'; import { messages } from '../../i18n/messages'; -import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog'; +import { + ConfirmDialog, + requestConfirmDialogDismiss, + type ConfirmDialogDismissReason, + type ConfirmDialogProps +} from './ConfirmDialog'; function render(overrides: Partial = {}) { return renderToStaticMarkup( @@ -13,6 +18,7 @@ function render(overrides: Partial = {}) { tone="danger" confirmLabel="Confirm It" busy={false} + activeDismissalPolicy={{ kind: 'blocked' }} onConfirm={() => undefined} onClose={() => undefined} {...overrides} @@ -95,8 +101,106 @@ describe('ConfirmDialog', () => { expect(render({ confirmDisabled: true })).toContain('disabled=""'); }); - it('can make every dismiss control visibly unavailable for an uncancellable action', () => { - const html = render({ busy: true, dismissDisabled: true }); - expect(html.match(/disabled=""/g)).toHaveLength(3); + it('blocks Escape, backdrop, close, and secondary dismissal during non-cancelable work', () => { + const onClose = vi.fn(); + for (const reason of [ + 'escape', + 'backdrop', + 'close-button', + 'secondary-action' + ] satisfies ConfirmDialogDismissReason[]) { + expect( + requestConfirmDialogDismiss({ + busy: true, + activeDismissalPolicy: { kind: 'blocked' }, + reason, + onClose + }) + ).toBe(false); + } + + expect(onClose).not.toHaveBeenCalled(); + const html = render({ busy: true }); + expect(html).toContain(messages.zh.operationCannotBeCancelled); + expect(html).not.toContain(`>${messages.zh.cancel}`); + }); + + it('routes every active dismissal surface through the detachable policy', () => { + const onClose = vi.fn(); + for (const reason of [ + 'escape', + 'backdrop', + 'close-button', + 'secondary-action' + ] satisfies ConfirmDialogDismissReason[]) { + expect( + requestConfirmDialogDismiss({ + busy: true, + activeDismissalPolicy: { + kind: 'detachable', + label: 'Hide and continue' + }, + reason, + onClose + }) + ).toBe(true); + } + + expect(onClose).toHaveBeenCalledTimes(4); + expect( + render({ + busy: true, + activeDismissalPolicy: { + kind: 'detachable', + label: 'Hide and continue' + } + }) + ).toContain('Hide and continue'); + }); + + it('routes every active dismissal surface through genuine cancellation', () => { + const onClose = vi.fn(); + const onCancel = vi.fn(); + for (const reason of [ + 'escape', + 'backdrop', + 'close-button', + 'secondary-action' + ] satisfies ConfirmDialogDismissReason[]) { + expect( + requestConfirmDialogDismiss({ + busy: true, + activeDismissalPolicy: { + kind: 'cancelable', + label: 'Cancel operation', + onCancel + }, + reason, + onClose + }) + ).toBe(true); + } + + expect(onCancel).toHaveBeenCalledTimes(4); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('uses ordinary close semantics before an operation starts', () => { + const onClose = vi.fn(); + const onCancel = vi.fn(); + expect( + requestConfirmDialogDismiss({ + busy: false, + activeDismissalPolicy: { + kind: 'cancelable', + label: 'Cancel operation', + onCancel + }, + reason: 'escape', + onClose + }) + ).toBe(true); + expect(onClose).toHaveBeenCalledOnce(); + expect(onCancel).not.toHaveBeenCalled(); }); }); diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx index 4ce0f720..c0d71d4f 100644 --- a/src/components/ui/ConfirmDialog.tsx +++ b/src/components/ui/ConfirmDialog.tsx @@ -6,11 +6,21 @@ import { type LucideIcon } from 'lucide-react'; import type { ReactNode } from 'react'; -import { Dialog } from './Dialog'; +import { Dialog, type DialogCloseReason } from './Dialog'; import { useI18n } from '../../i18n/LocaleProvider'; export type ConfirmTone = 'gold' | 'danger'; +export type ConfirmDialogDismissReason = + | DialogCloseReason + | 'close-button' + | 'secondary-action'; + +export type ActiveDismissalPolicy = + | { kind: 'blocked' } + | { kind: 'detachable'; label: string } + | { kind: 'cancelable'; label: string; onCancel: () => void }; + export interface ConfirmAcknowledge { /** Already-localized label (pass t('...')). */ label: string; @@ -42,12 +52,13 @@ export interface ConfirmDialogProps { busyLabel?: string; /** Disables confirm + triggers busy affordance. */ busy: boolean; - /** Also disables Escape/backdrop/X/cancel while a native operation that - * cannot be cancelled is in flight. */ - dismissDisabled?: boolean; + /** Explicitly defines what every dismiss surface means while busy. */ + activeDismissalPolicy: ActiveDismissalPolicy; + /** Replaces ordinary Cancel wording after a failed operation. */ + dismissLabel?: string; /** Extra gate (for example, Cleanup having nothing to clean). */ confirmDisabled?: boolean; - onConfirm: () => void | Promise; + onConfirm: () => unknown; onClose: () => void; } @@ -93,7 +104,8 @@ export function ConfirmDialog({ confirmLabel, busyLabel, busy, - dismissDisabled = false, + activeDismissalPolicy, + dismissLabel, confirmDisabled, onConfirm, onClose @@ -101,12 +113,24 @@ export function ConfirmDialog({ const { t } = useI18n(); const s = TONE[tone]; const Icon = s.Icon; + const dismissAllowed = !busy || activeDismissalPolicy.kind !== 'blocked'; + const activeDismissLabel = + activeDismissalPolicy.kind === 'blocked' + ? null + : activeDismissalPolicy.label; + const secondaryLabel = busy + ? activeDismissLabel + : (dismissLabel ?? t('cancel')); + const requestDismiss = (reason: ConfirmDialogDismissReason) => + requestConfirmDialogDismiss({ + busy, + activeDismissalPolicy, + reason, + onClose + }); return ( - undefined : onClose} - labelledBy={titleId} - > +
@@ -117,10 +141,12 @@ export function ConfirmDialog({
@@ -133,20 +159,30 @@ export function ConfirmDialog({ type="checkbox" className="mt-1" checked={acknowledge.checked} + disabled={busy} onChange={(event) => acknowledge.onChange(event.target.checked)} /> {acknowledge.label} )}
- + {secondaryLabel ? ( + + ) : ( +

+ {t('operationCannotBeCancelled')} +

+ )}
); } + +export type DialogCloseReason = 'escape' | 'backdrop'; diff --git a/src/features/history/StorageCleanupCard.tsx b/src/features/history/StorageCleanupCard.tsx index 8a301347..27bf4aa1 100644 --- a/src/features/history/StorageCleanupCard.tsx +++ b/src/features/history/StorageCleanupCard.tsx @@ -1,9 +1,14 @@ import { ChevronRight, Trash2 } from 'lucide-react'; import { ConfirmDialog } from '../../components/ui/ConfirmDialog'; -import { ErrorBanner } from '../../components/ui/ErrorBanner'; +import { ProblemBanner } from '../../components/ui/ProblemBanner'; import { useI18n } from '../../i18n/LocaleProvider'; import type { StorageCleanupPreset } from '../../types/backend'; import { formatBytes } from './format'; +import { formatProblemDiagnostic } from '../shared/problems'; +import { + presentStorageCleanupProblem, + type StorageCleanupProblem +} from './storageCleanupProblems'; import { useStorageCleanup, type CleanupOutcome, @@ -91,7 +96,9 @@ export function StorageCleanupCard({
- {cleanup.error && } + {cleanup.previewProblem && ( + + )} +

+ {t('storageCleanupTarget', { + scope: + cleanup.pending.scope === 'screenshots' + ? t('storageCleanupScreenshotsLabel') + : t('storageCleanupRunDataLabel'), + preset: t( + PRESETS.find(({ preset }) => preset === cleanup.pending?.preset) + ?.labelKey ?? 'storageCleanupPresetAll' + ) + })} +

{pendingBody(cleanup.pending)}

@@ -135,12 +165,30 @@ export function StorageCleanupCard({ })}

)} + {cleanup.problem && ( + + )} )} ); } +function StorageCleanupProblemBanner({ + problem +}: { + problem: StorageCleanupProblem; +}) { + const { t } = useI18n(); + return ( + + ); +} + function CleanupRow({ label, scope, diff --git a/src/features/history/storageCleanupProblems.test.ts b/src/features/history/storageCleanupProblems.test.ts new file mode 100644 index 00000000..302b4601 --- /dev/null +++ b/src/features/history/storageCleanupProblems.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { formatMessage } from '../../i18n/messages'; +import { + presentStorageCleanupProblem, + storageCleanupProblemFromError +} from './storageCleanupProblems'; + +describe('storage cleanup problems', () => { + it.each(['preview_storage_cleanup', 'execute_storage_cleanup'])( + 'preserves %s semantics and localizes without exposing diagnostics', + (operation) => { + const problem = storageCleanupProblemFromError({ + code: 'history_action_failed', + params: { operation }, + diagnostic: 'database is locked' + }); + const zh = presentStorageCleanupProblem(problem, (key, params) => + formatMessage('zh', key, params) + ); + const en = presentStorageCleanupProblem(problem, (key, params) => + formatMessage('en', key, params) + ); + + expect(problem.params.operation).toBe(operation); + expect(zh).not.toContain('database is locked'); + expect(en).not.toContain('database is locked'); + expect(zh).not.toBe(en); + } + ); +}); diff --git a/src/features/history/storageCleanupProblems.ts b/src/features/history/storageCleanupProblems.ts new file mode 100644 index 00000000..53fc13d7 --- /dev/null +++ b/src/features/history/storageCleanupProblems.ts @@ -0,0 +1,53 @@ +import type { Translate } from '../../i18n/LocaleProvider'; +import type { MessageKey } from '../../i18n/messages'; +import { + createUiProblem, + problemFromError, + type UiProblem +} from '../shared/problems'; + +export type StorageCleanupProblemCode = + | 'history_unavailable' + | 'history_action_failed' + | 'storage_cleanup_unexpected'; + +export type StorageCleanupProblem = UiProblem; + +export function storageCleanupProblemFromError( + error: unknown +): StorageCleanupProblem { + const problem = problemFromError(error, 'storage_cleanup_unexpected'); + switch (problem.code) { + case 'history_unavailable': + case 'history_action_failed': + case 'storage_cleanup_unexpected': + return problem as StorageCleanupProblem; + default: + return createUiProblem('storage_cleanup_unexpected', { + params: problem.params, + diagnostic: problem.diagnostic + }); + } +} + +export function presentStorageCleanupProblem( + problem: StorageCleanupProblem, + t: Translate +): string { + return t(storageCleanupProblemMessageKey(problem), problem.params); +} + +function storageCleanupProblemMessageKey( + problem: StorageCleanupProblem +): MessageKey { + switch (problem.code) { + case 'history_unavailable': + return 'storageCleanupProblemUnavailable'; + case 'history_action_failed': + return problem.params.operation === 'preview_storage_cleanup' + ? 'storageCleanupProblemPreviewFailed' + : 'storageCleanupProblemExecuteFailed'; + case 'storage_cleanup_unexpected': + return 'storageCleanupProblemUnexpected'; + } +} diff --git a/src/features/history/useRunDetailPage.ts b/src/features/history/useRunDetailPage.ts index afe10e3b..0f285295 100644 --- a/src/features/history/useRunDetailPage.ts +++ b/src/features/history/useRunDetailPage.ts @@ -19,7 +19,11 @@ import { type RunDetailActionState, type RunDetailActionTarget } from './runDetailPageState'; -import { runDetailProblemFromError } from './runDetailProblems'; +import { + runDetailProblemFromError, + type RunDetailProblem +} from './runDetailProblems'; +import type { ConfirmedOperationOutcome } from '../shared/confirmedOperation'; export function useRunDetailPage() { const { runId } = useParams<{ runId: string }>(); @@ -78,11 +82,28 @@ export function useRunDetailPage() { }, [load]); const runAction = useCallback( - async (name: RunDetailActionName, task: () => Promise) => { - if (requestInFlightRef.current) return false; + async ( + name: RunDetailActionName, + task: () => Promise + ): Promise> => { + if (requestInFlightRef.current) { + return { + ok: false, + problem: runDetailProblemFromError( + new Error('Run Detail request is already in progress.') + ) + }; + } const previous = actionStateRef.current; const started = beginRunDetailAction(previous, name); - if (started === previous) return false; + if (started === previous) { + return { + ok: false, + problem: runDetailProblemFromError( + new Error('Another Run Detail action is already in progress.') + ) + }; + } commitActionState(started); try { @@ -90,16 +111,13 @@ export function useRunDetailPage() { commitActionState( completeRunDetailAction(actionStateRef.current, name) ); - return true; + return { ok: true }; } catch (caught) { + const problem = runDetailProblemFromError(caught); commitActionState( - failRunDetailAction( - actionStateRef.current, - name, - runDetailProblemFromError(caught) - ) + failRunDetailAction(actionStateRef.current, name, problem) ); - return false; + return { ok: false, problem }; } }, [commitActionState] @@ -107,17 +125,21 @@ export function useRunDetailPage() { const revealScreenshot = useCallback(async () => { if (state.phase !== 'ready') return false; - return runAction('screenshot', () => - revealRunScreenshot(state.data.run.run_id) - ); + return ( + await runAction('screenshot', () => + revealRunScreenshot(state.data.run.run_id) + ) + ).ok; }, [runAction, state]); const revealVideo = useCallback( async (battleId: string, videoId?: string) => { if (state.phase !== 'ready' || !videoId) return false; - return runAction(`video:${battleId}`, () => - revealBattleVideo(battleId, videoId) - ); + return ( + await runAction(`video:${battleId}`, () => + revealBattleVideo(battleId, videoId) + ) + ).ok; }, [runAction, state.phase] ); diff --git a/src/features/history/useStorageCleanup.ts b/src/features/history/useStorageCleanup.ts index bdff6c1f..1334c08e 100644 --- a/src/features/history/useStorageCleanup.ts +++ b/src/features/history/useStorageCleanup.ts @@ -1,12 +1,16 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import type { StorageCleanupExecution, StorageCleanupPreset, StorageCleanupPreview, StorageCleanupScope } from '../../types/backend'; -import { useAsyncAction } from '../shared/useAsyncAction'; +import { useConfirmedOperation } from '../shared/confirmedOperation'; import { executeStorageCleanup, previewStorageCleanup } from './historyApi'; +import { + storageCleanupProblemFromError, + type StorageCleanupProblem +} from './storageCleanupProblems'; export type CleanupScope = StorageCleanupScope; @@ -17,41 +21,59 @@ export type PendingCleanup = StorageCleanupPreview & { export type CleanupOutcome = StorageCleanupExecution; export function useStorageCleanup(onCompleted: () => Promise | void) { - const [pending, setPending] = useState(null); const [outcome, setOutcome] = useState(null); - const { busy, error, clearError, run } = useAsyncAction< - 'preview' | 'execute' + const [previewing, setPreviewing] = useState(false); + const [previewProblem, setPreviewProblem] = + useState(null); + const previewInFlight = useRef(false); + const operation = useConfirmedOperation< + PendingCleanup, + StorageCleanupProblem >(); - const requestCleanup = (scope: CleanupScope, preset: StorageCleanupPreset) => - run('preview', async () => { - setOutcome(null); + const requestCleanup = async ( + scope: CleanupScope, + preset: StorageCleanupPreset + ) => { + if (previewInFlight.current || operation.controller.getSnapshot()) { + return false; + } + previewInFlight.current = true; + setPreviewing(true); + setPreviewProblem(null); + setOutcome(null); + try { const preview = await previewStorageCleanup(scope, preset); - setPending({ ...preview, preset }); - }); - - const confirm = () => { - const target = pending; - if (!target) { - return; + return operation.controller.request({ ...preview, preset }); + } catch (caught) { + setPreviewProblem(storageCleanupProblemFromError(caught)); + return false; + } finally { + previewInFlight.current = false; + setPreviewing(false); } - void run('execute', async () => { - setPending(null); - setOutcome(await executeStorageCleanup(target.scope, target.preset)); - await onCompleted(); - }); }; - const cancel = () => setPending(null); + const confirm = () => + operation.controller.run(async (target) => { + const result = await executeStorageCleanup(target.scope, target.preset); + await onCompleted(); + setOutcome(result); + return { ok: true }; + }, storageCleanupProblemFromError); + + const pending = operation.state?.target ?? null; return { pending, outcome, - busy, - error, - clearError, + operation: operation.state, + problem: + operation.state?.phase === 'failed' ? operation.state.problem : null, + previewProblem, + busy: previewing || operation.state?.phase === 'running', requestCleanup, confirm, - cancel + cancel: operation.controller.dismiss }; } diff --git a/src/features/install/InstallConfirmModal.tsx b/src/features/install/InstallConfirmModal.tsx index 38fc41b1..d9c86fac 100644 --- a/src/features/install/InstallConfirmModal.tsx +++ b/src/features/install/InstallConfirmModal.tsx @@ -37,6 +37,7 @@ export function InstallConfirmModal({ confirmLabel={t('confirmInstall')} busyLabel={t('installing')} busy={busy} + activeDismissalPolicy={{ kind: 'blocked' }} onConfirm={onConfirm} onClose={onClose} > diff --git a/src/features/install/ResetBepinexConfirmModal.tsx b/src/features/install/ResetBepinexConfirmModal.tsx index 45e1a7b9..dc48469d 100644 --- a/src/features/install/ResetBepinexConfirmModal.tsx +++ b/src/features/install/ResetBepinexConfirmModal.tsx @@ -1,16 +1,22 @@ import { FolderX, ShieldCheck } from 'lucide-react'; import { ConfirmDialog } from '../../components/ui/ConfirmDialog'; import { useI18n } from '../../i18n/LocaleProvider'; +import { InstallProblemBanner } from './InstallProblemBanner'; +import type { InstallProblem } from './installProblems'; export function ResetBepinexConfirmModal({ busy, acknowledged, + targetPath, + problem, onAcknowledgedChange, onClose, onConfirm }: { busy: boolean; acknowledged: boolean; + targetPath: string; + problem: InstallProblem | null; onAcknowledgedChange: (acknowledged: boolean) => void; onClose: () => void; onConfirm: () => void | Promise; @@ -27,11 +33,17 @@ export function ResetBepinexConfirmModal({ checked: acknowledged, onChange: onAcknowledgedChange }} - confirmLabel={t('resetBepinexConfirmAction')} + confirmLabel={problem ? t('retry') : t('resetBepinexConfirmAction')} + busyLabel={t('resetBepinexRunning')} busy={busy} + activeDismissalPolicy={{ kind: 'blocked' }} + dismissLabel={problem ? t('close') : undefined} onConfirm={onConfirm} onClose={onClose} > +

+ {t('resetBepinexTarget', { path: targetPath })} +

{t('resetBepinexConfirmGameClosed')}

+ {problem && } ); } diff --git a/src/features/install/ResetDataConfirmModal.tsx b/src/features/install/ResetDataConfirmModal.tsx index eb651d00..4c23cb9d 100644 --- a/src/features/install/ResetDataConfirmModal.tsx +++ b/src/features/install/ResetDataConfirmModal.tsx @@ -1,16 +1,25 @@ import { Database, ShieldCheck } from 'lucide-react'; import { ConfirmDialog } from '../../components/ui/ConfirmDialog'; import { useI18n } from '../../i18n/LocaleProvider'; +import { InstallProblemBanner } from './InstallProblemBanner'; +import { ResetDataFailureDetails } from './ResetDataFailureDetails'; +import type { InstallProblem } from './installProblems'; export function ResetDataConfirmModal({ busy, acknowledged, + targetPath, + problem, + failurePaths, onAcknowledgedChange, onClose, onConfirm }: { busy: boolean; acknowledged: boolean; + targetPath: string; + problem: InstallProblem | null; + failurePaths: string[]; onAcknowledgedChange: (acknowledged: boolean) => void; onClose: () => void; onConfirm: () => void | Promise; @@ -27,11 +36,17 @@ export function ResetDataConfirmModal({ checked: acknowledged, onChange: onAcknowledgedChange }} - confirmLabel={t('resetDataConfirmAction')} + confirmLabel={problem ? t('retry') : t('resetDataConfirmAction')} + busyLabel={t('resetDataRunning')} busy={busy} + activeDismissalPolicy={{ kind: 'blocked' }} + dismissLabel={problem ? t('close') : undefined} onConfirm={onConfirm} onClose={onClose} > +

+ {t('resetDataTarget', { path: targetPath })} +

{t('resetDataConfirmGameClosed')}

+ {problem && } + {failurePaths.length > 0 && ( + + )} ); } diff --git a/src/features/install/useInstallPage.ts b/src/features/install/useInstallPage.ts index 97c2dd37..e6e7b772 100644 --- a/src/features/install/useInstallPage.ts +++ b/src/features/install/useInstallPage.ts @@ -31,6 +31,9 @@ import { presentInstallProblem, type InstallProblem } from './installProblems'; +import type { ConfirmedOperationOutcome } from '../shared/confirmedOperation'; + +export type InstallActionResult = ConfirmedOperationOutcome; export function useInstallPage() { const { t } = useI18n(); @@ -98,11 +101,22 @@ export function useInstallPage() { ); const runInstallAction = useCallback( - async (name: InstallOperation, task: () => Promise) => { - if (requestInFlightRef.current || actionInFlightRef.current) return false; + async ( + name: InstallOperation, + task: () => Promise + ): Promise => { + if (requestInFlightRef.current || actionInFlightRef.current) { + return { + ok: false, + problem: installProblemFromError( + new Error('Another Install action is already in progress.') + ) + }; + } actionInFlightRef.current = true; + let actionFailure: InstallProblem | null = null; try { - return await run(name, task, { + const completed = await run(name, task, { onStart: () => { setTransient(null); setActionProblem(null); @@ -110,11 +124,21 @@ export function useInstallPage() { }, errorMessage: (caught) => { const problem = installProblemFromError(caught); + actionFailure = problem; setActionProblem(problem); setResetDataFailurePaths(installFailurePaths(problem)); return presentInstallProblem(problem, t); } }); + if (completed) return { ok: true }; + return { + ok: false, + problem: + actionFailure ?? + installProblemFromError( + new Error('Install action did not start or finish.') + ) + }; } finally { actionInFlightRef.current = false; } diff --git a/src/features/shared/confirmedOperation.test.ts b/src/features/shared/confirmedOperation.test.ts new file mode 100644 index 00000000..cbb81ff8 --- /dev/null +++ b/src/features/shared/confirmedOperation.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createConfirmedOperationController, + type ConfirmedOperationOutcome +} from './confirmedOperation'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('confirmed operation controller', () => { + it('keeps a non-cancelable target visible, blocks dismissal and repeated submission, then closes on success', async () => { + const controller = createConfirmedOperationController< + { kind: 'cleanup'; scope: 'screenshots' }, + string + >(); + const completion = deferred>(); + const execute = vi.fn(() => completion.promise); + + expect(controller.request({ kind: 'cleanup', scope: 'screenshots' })).toBe( + true + ); + const first = controller.run(execute, String); + + expect(controller.getSnapshot()).toMatchObject({ + phase: 'running', + target: { kind: 'cleanup', scope: 'screenshots' } + }); + expect(controller.dismiss()).toBe(false); + await expect(controller.run(execute, String)).resolves.toBe(false); + expect(execute).toHaveBeenCalledTimes(1); + + completion.resolve({ ok: true }); + await expect(first).resolves.toBe(true); + expect(controller.getSnapshot()).toBeNull(); + }); + + it('keeps target and problem on failure, supports retry, and allows a safe exit', async () => { + const controller = createConfirmedOperationController< + { kind: 'delete-video'; battleId: string }, + string + >(); + controller.request({ kind: 'delete-video', battleId: 'battle-7' }); + + await expect( + controller.run( + async () => ({ ok: false, problem: 'delete failed' }), + String + ) + ).resolves.toBe(false); + expect(controller.getSnapshot()).toEqual({ + phase: 'failed', + target: { kind: 'delete-video', battleId: 'battle-7' }, + problem: 'delete failed' + }); + + await expect( + controller.run(async () => ({ ok: true }), String) + ).resolves.toBe(true); + expect(controller.getSnapshot()).toBeNull(); + + controller.request({ kind: 'delete-video', battleId: 'battle-8' }); + expect(controller.dismiss()).toBe(true); + expect(controller.getSnapshot()).toBeNull(); + }); + + it.each([ + ['cleanup', { kind: 'cleanup', target: 'run_data:before_this_month' }], + ['reset', { kind: 'reset-data', target: '/game/BazaarPlusPlusV4' }], + ['delete', { kind: 'delete-video', target: 'battle-2/video-3' }] + ])( + '%s feature uses the shared success/failure lifecycle', + async (_, target) => { + const controller = createConfirmedOperationController< + { kind: string; target: string }, + string + >(); + controller.request(target); + await controller.run( + async () => ({ ok: false, problem: 'failed' }), + String + ); + expect(controller.getSnapshot()).toEqual({ + phase: 'failed', + target, + problem: 'failed' + }); + await controller.run(async () => ({ ok: true }), String); + expect(controller.getSnapshot()).toBeNull(); + } + ); +}); diff --git a/src/features/shared/confirmedOperation.ts b/src/features/shared/confirmedOperation.ts new file mode 100644 index 00000000..e4267d0a --- /dev/null +++ b/src/features/shared/confirmedOperation.ts @@ -0,0 +1,94 @@ +import { useMemo, useSyncExternalStore } from 'react'; + +export type ConfirmedOperationOutcome = + | { ok: true } + | { ok: false; problem: TProblem }; + +export type ConfirmedOperationState = + | { + phase: 'confirming' | 'running'; + target: TTarget; + problem: null; + } + | { + phase: 'failed'; + target: TTarget; + problem: TProblem; + } + | null; + +export interface ConfirmedOperationController { + getSnapshot(): ConfirmedOperationState; + subscribe(listener: () => void): () => void; + request(target: TTarget): boolean; + dismiss(): boolean; + run( + execute: (target: TTarget) => Promise>, + problemFromError: (error: unknown) => TProblem + ): Promise; +} + +export function createConfirmedOperationController< + TTarget, + TProblem +>(): ConfirmedOperationController { + let state: ConfirmedOperationState = null; + const listeners = new Set<() => void>(); + + const publish = (next: ConfirmedOperationState) => { + state = next; + for (const listener of listeners) listener(); + }; + + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + request: (target) => { + if (state !== null) return false; + publish({ phase: 'confirming', target, problem: null }); + return true; + }, + dismiss: () => { + if (!state || state.phase === 'running') return false; + publish(null); + return true; + }, + run: async (execute, problemFromError) => { + if (!state || state.phase === 'running') return false; + const target = state.target; + publish({ phase: 'running', target, problem: null }); + + let outcome: ConfirmedOperationOutcome; + try { + outcome = await execute(target); + } catch (caught) { + outcome = { ok: false, problem: problemFromError(caught) }; + } + + if (outcome.ok) { + publish(null); + return true; + } + + publish({ phase: 'failed', target, problem: outcome.problem }); + return false; + } + }; +} + +export function useConfirmedOperation() { + const controller = useMemo( + () => createConfirmedOperationController(), + [] + ); + const state = useSyncExternalStore( + controller.subscribe, + controller.getSnapshot, + controller.getSnapshot + ); + + return { state, controller }; +} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 2cdab324..8edefe18 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -94,6 +94,7 @@ const zh = { modNotInstalled: '尚未安装', installDone: '安装完成', resetDataConfirmTitle: '重置本地数据', + resetDataTarget: '目标:{path} 内的 BazaarPlusPlusV4 文件夹', resetDataConfirmBody: '这会删除 The Bazaar 安装目录中 BazaarPlusPlusV4 下的本地数据库、截图和战斗回放视频。', resetDataConfirmKeepsInstall: @@ -101,6 +102,7 @@ const zh = { resetDataConfirmGameClosed: '请先退出 The Bazaar,避免数据文件仍被占用。', resetDataConfirmAcknowledge: '我知道这些本地数据会被删除。', resetDataConfirmAction: '删除本地数据', + resetDataRunning: '正在删除目标本地数据…', resetDataDone: '本地数据已删除', resetDataNothingToDelete: '未找到可重置的本地数据', resetDataBlockedByGame: 'The Bazaar 仍在运行。请先退出游戏,再重置本地数据。', @@ -111,6 +113,7 @@ const zh = { resetDataFailureCopied: '已复制', resetDataFailureCopyFailed: '复制失败,请手动选择文本复制', resetBepinexConfirmTitle: '重置 BepInEx 文件夹', + resetBepinexTarget: '目标:{path} 内的 BepInEx 文件夹', resetBepinexConfirmBody: '这会删除 The Bazaar 安装目录中的整个 BepInEx 文件夹。', resetBepinexConfirmOtherMods: @@ -121,11 +124,13 @@ const zh = { resetBepinexConfirmAcknowledge: '我知道整个 BepInEx 文件夹(含其他模组)会被删除。', resetBepinexConfirmAction: '删除 BepInEx 文件夹', + resetBepinexRunning: '正在删除目标 BepInEx 文件夹…', resetBepinexDone: 'BepInEx 文件夹已删除', resetBepinexNothingToDelete: '未找到 BepInEx 文件夹', resetBepinexBlockedByGame: 'The Bazaar 仍在运行。请先退出游戏,再重置 BepInEx 文件夹。', resetBepinexPartialFailure: '有 {count} 个项目未能删除。请关闭游戏后重试。', + operationCannotBeCancelled: '操作已开始,完成前无法取消或关闭此窗口。', uninstallDone: '卸载完成', selectGameDirFirst: '请先选择 The Bazaar 安装目录。', installWarningGameMissing: @@ -245,8 +250,10 @@ const zh = { openVideoLocation: '打开视频位置', deleteVideo: '删除视频', deleteVideoConfirmTitle: '删除视频', + deleteVideoTarget: '目标:战斗 {battleId} · 视频 {videoId}', deleteVideoConfirmBody: '这会永久删除该场战斗的回放视频文件,且无法恢复。', deleteVideoConfirmAction: '删除视频', + deleteVideoRunning: '正在删除目标视频…', // Storage cleanup (History page) storageCleanupTitle: '存储清理', @@ -256,6 +263,7 @@ const zh = { storageCleanupPresetOlderThan7Days: '清理 7 天前', storageCleanupPresetBeforeThisMonth: '仅保留本月', storageCleanupConfirmTitle: '确认清理', + storageCleanupTarget: '目标:{scope} · 范围:{preset}', storageCleanupScreenshotsConfirmBody: '将删除 {count} 张结算截图(约 {size}),删除后无法恢复。', storageCleanupRunDataConfirmBody: @@ -263,6 +271,14 @@ const zh = { storageCleanupSkippedPending: '另有 {count} 项尚未完成上传,将自动跳过。', storageCleanupNothingToClean: '没有符合条件的可清理数据。', storageCleanupConfirmAction: '确认清理', + storageCleanupRunningScreenshots: '正在删除结算截图…', + storageCleanupRunningRunData: '正在删除对局数据…', + storageCleanupProblemUnavailable: + '未找到可清理的本地战绩数据库,请先在安装页选择正确的游戏目录。', + storageCleanupProblemPreviewFailed: '无法预览清理范围,请重试。', + storageCleanupProblemExecuteFailed: + '清理未完成,目标和范围已保留;请查看诊断后重试或安全关闭。', + storageCleanupProblemUnexpected: '清理存储时发生意外错误,请重试。', storageCleanupScreenshotsDone: '已删除 {files} 个文件,释放约 {size}。', storageCleanupRunDataDone: '已删除 {runs} 局对局和 {files} 个文件,释放约 {size}。', @@ -411,6 +427,7 @@ const en: Record = { modNotInstalled: 'Not installed yet', installDone: 'Install complete', resetDataConfirmTitle: 'Reset Local Data', + resetDataTarget: 'Target: the BazaarPlusPlusV4 folder inside {path}', resetDataConfirmBody: 'This deletes the local database, screenshots, and combat replay videos under BazaarPlusPlusV4 in The Bazaar install directory.', resetDataConfirmKeepsInstall: @@ -419,6 +436,7 @@ const en: Record = { 'Quit The Bazaar first so data files are not held open.', resetDataConfirmAcknowledge: 'I understand this local data will be deleted.', resetDataConfirmAction: 'Delete Local Data', + resetDataRunning: 'Deleting the target local data…', resetDataDone: 'Local data deleted', resetDataNothingToDelete: 'No resettable local data found', resetDataBlockedByGame: @@ -430,6 +448,7 @@ const en: Record = { resetDataFailureCopied: 'Copied', resetDataFailureCopyFailed: 'Copy failed. Select the text and copy manually.', resetBepinexConfirmTitle: 'Reset BepInEx Folder', + resetBepinexTarget: 'Target: the BepInEx folder inside {path}', resetBepinexConfirmBody: 'This deletes the entire BepInEx folder in The Bazaar install directory.', resetBepinexConfirmOtherMods: @@ -441,12 +460,15 @@ const en: Record = { resetBepinexConfirmAcknowledge: 'I understand the entire BepInEx folder (including other mods) will be deleted.', resetBepinexConfirmAction: 'Delete BepInEx Folder', + resetBepinexRunning: 'Deleting the target BepInEx folder…', resetBepinexDone: 'BepInEx folder deleted', resetBepinexNothingToDelete: 'No BepInEx folder found', resetBepinexBlockedByGame: 'The Bazaar is still running. Quit the game before resetting the BepInEx folder.', resetBepinexPartialFailure: '{count} item(s) could not be deleted. Close the game, then try again.', + operationCannotBeCancelled: + 'This operation has started and cannot be canceled or dismissed until it finishes.', uninstallDone: 'Uninstall complete', selectGameDirFirst: 'Select The Bazaar install directory first.', installWarningGameMissing: @@ -570,9 +592,11 @@ const en: Record = { openVideoLocation: 'Open video location', deleteVideo: 'Delete video', deleteVideoConfirmTitle: 'Delete Video', + deleteVideoTarget: 'Target: battle {battleId} · video {videoId}', deleteVideoConfirmBody: 'This permanently deletes the replay video file for this battle and cannot be undone.', deleteVideoConfirmAction: 'Delete Video', + deleteVideoRunning: 'Deleting the target video…', storageCleanupTitle: 'Storage Cleanup', storageCleanupScreenshotsLabel: 'End-of-run screenshots', @@ -581,6 +605,7 @@ const en: Record = { storageCleanupPresetOlderThan7Days: 'Older than 7 days', storageCleanupPresetBeforeThisMonth: 'Keep this month only', storageCleanupConfirmTitle: 'Confirm Cleanup', + storageCleanupTarget: 'Target: {scope} · Range: {preset}', storageCleanupScreenshotsConfirmBody: 'This will permanently delete {count} end-of-run screenshots (about {size}). This cannot be undone.', storageCleanupRunDataConfirmBody: @@ -589,6 +614,16 @@ const en: Record = { '{count} items are still pending upload and will be skipped.', storageCleanupNothingToClean: 'Nothing matches the selected range.', storageCleanupConfirmAction: 'Clean Up', + storageCleanupRunningScreenshots: 'Deleting end-of-run screenshots…', + storageCleanupRunningRunData: 'Deleting run data…', + storageCleanupProblemUnavailable: + 'No local History database is available to clean. Select the correct game folder on the Install page.', + storageCleanupProblemPreviewFailed: + 'The cleanup range could not be previewed. Please retry.', + storageCleanupProblemExecuteFailed: + 'Cleanup did not finish. The target and range are preserved; review diagnostics, then retry or close safely.', + storageCleanupProblemUnexpected: + 'Something unexpected happened while cleaning storage. Please retry.', storageCleanupScreenshotsDone: 'Deleted {files} files, freed about {size}.', storageCleanupRunDataDone: 'Deleted {runs} runs and {files} files, freed about {size}.', diff --git a/src/pages/Install.tsx b/src/pages/Install.tsx index 56a150e7..a358ad27 100644 --- a/src/pages/Install.tsx +++ b/src/pages/Install.tsx @@ -9,13 +9,25 @@ import { ResetDataConfirmModal } from '../features/install/ResetDataConfirmModal import { useInstallPage } from '../features/install/useInstallPage'; import { useI18n } from '../i18n/LocaleProvider'; import { InstallProblemBanner } from '../features/install/InstallProblemBanner'; +import { useConfirmedOperation } from '../features/shared/confirmedOperation'; +import { + installProblemFromError, + type InstallProblem +} from '../features/install/installProblems'; + +type InstallResetTarget = { + kind: 'reset-data' | 'reset-bepinex'; + gamePath: string; +}; export default function Install() { const { t } = useI18n(); const page = useInstallPage(); const [showInstallModal, setShowInstallModal] = useState(false); - const [showResetDataModal, setShowResetDataModal] = useState(false); - const [showResetBepinexModal, setShowResetBepinexModal] = useState(false); + const resetOperation = useConfirmedOperation< + InstallResetTarget, + InstallProblem + >(); const [installAcknowledged, setInstallAcknowledged] = useState(false); const [resetDataAcknowledged, setResetDataAcknowledged] = useState(false); const [resetBepinexAcknowledged, setResetBepinexAcknowledged] = @@ -32,32 +44,43 @@ export default function Install() { const confirmInstall = async () => { const installed = await page.install(compatOptIn); - if (installed) { + if (installed.ok) { setShowInstallModal(false); setInstallAcknowledged(false); } }; const openResetDataModal = () => { - setShowResetDataModal(true); - setResetDataAcknowledged(false); - }; - - const confirmResetData = async () => { - await page.resetData(); - setShowResetDataModal(false); + const gamePath = page.installState?.selected_game_path; + if (!gamePath) return; + resetOperation.controller.request({ kind: 'reset-data', gamePath }); setResetDataAcknowledged(false); }; const openResetBepinexModal = () => { - setShowResetBepinexModal(true); + const gamePath = page.installState?.selected_game_path; + if (!gamePath) return; + resetOperation.controller.request({ kind: 'reset-bepinex', gamePath }); setResetBepinexAcknowledged(false); }; - const confirmResetBepinex = async () => { - await page.resetBepinex(); - setShowResetBepinexModal(false); - setResetBepinexAcknowledged(false); + const confirmReset = async () => { + const completed = await resetOperation.controller.run( + (target) => + target.kind === 'reset-data' ? page.resetData() : page.resetBepinex(), + installProblemFromError + ); + if (completed) { + setResetDataAcknowledged(false); + setResetBepinexAcknowledged(false); + } + }; + + const closeReset = () => { + if (resetOperation.controller.dismiss()) { + setResetDataAcknowledged(false); + setResetBepinexAcknowledged(false); + } }; return ( @@ -117,25 +140,40 @@ export default function Install() { /> )} - {showResetDataModal && page.installState && ( - setShowResetDataModal(false)} - onConfirm={confirmResetData} - /> - )} + {resetOperation.state?.target.kind === 'reset-data' && + page.installState && ( + + )} - {showResetBepinexModal && page.installState && ( - setShowResetBepinexModal(false)} - onConfirm={confirmResetBepinex} - /> - )} + {resetOperation.state?.target.kind === 'reset-bepinex' && + page.installState && ( + + )} ); } diff --git a/src/pages/RunDetail.tsx b/src/pages/RunDetail.tsx index 5c5e7c8d..2c30c08a 100644 --- a/src/pages/RunDetail.tsx +++ b/src/pages/RunDetail.tsx @@ -7,7 +7,6 @@ import { Trash2, Video } from 'lucide-react'; -import { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { ConfirmDialog } from '../components/ui/ConfirmDialog'; import { LoadingPanel } from '../components/ui/LoadingPanel'; @@ -25,9 +24,11 @@ import { } from '../features/history/format'; import { presentRunDetailProblem, + runDetailProblemFromError, type RunDetailProblem } from '../features/history/runDetailProblems'; import { formatProblemDiagnostic } from '../features/shared/problems'; +import { useConfirmedOperation } from '../features/shared/confirmedOperation'; import { useI18n } from '../i18n/LocaleProvider'; // Shared 7-track grid for the battle table header + rows so columns align and @@ -42,26 +43,19 @@ export default function RunDetail() { const detail = page.detail; const { locale, t } = useI18n(); const runResult = detail ? formatRunResultLabel(detail.run.result) : null; - const [pendingDelete, setPendingDelete] = useState<{ - battleId: string; - videoId: string; - } | null>(null); + const deleteOperation = useConfirmedOperation< + { kind: 'delete-video'; battleId: string; videoId: string }, + RunDetailProblem + >(); + const pendingDelete = deleteOperation.state?.target ?? null; const screenshotAvailability = page.availability('screenshot'); const screenshotFailure = page.problemFor('screenshot'); - const pendingDeleteFailure = pendingDelete - ? page.problemFor(`battle:${pendingDelete.battleId}`) - : null; - const confirmDelete = async () => { - if (!pendingDelete) return; - const deleted = await page.deleteVideo( - pendingDelete.battleId, - pendingDelete.videoId + const confirmDelete = () => + deleteOperation.controller.run( + (target) => page.deleteVideo(target.battleId, target.videoId), + runDetailProblemFromError ); - if (deleted) { - setPendingDelete(null); - } - }; return (
@@ -234,7 +228,11 @@ export default function RunDetail() { battle={battle} page={page} onRequestDelete={(battleId, videoId) => - setPendingDelete({ battleId, videoId }) + deleteOperation.controller.request({ + kind: 'delete-video', + battleId, + videoId + }) } /> )) @@ -250,20 +248,31 @@ export default function RunDetail() { titleId="delete-video-modal-title" title={t('deleteVideoConfirmTitle')} tone="danger" - confirmLabel={t('deleteVideoConfirmAction')} - busy={page.action === `delete:${pendingDelete.battleId}`} - dismissDisabled={page.action === `delete:${pendingDelete.battleId}`} + confirmLabel={ + deleteOperation.state?.phase === 'failed' + ? t('retry') + : t('deleteVideoConfirmAction') + } + busyLabel={t('deleteVideoRunning')} + busy={deleteOperation.state?.phase === 'running'} + activeDismissalPolicy={{ kind: 'blocked' }} + dismissLabel={ + deleteOperation.state?.phase === 'failed' ? t('close') : undefined + } onConfirm={confirmDelete} - onClose={() => setPendingDelete(null)} + onClose={deleteOperation.controller.dismiss} > +

+ {t('deleteVideoTarget', { + battleId: pendingDelete.battleId, + videoId: pendingDelete.videoId + })} +

{t('deleteVideoConfirmBody')}

- {pendingDeleteFailure?.action.startsWith('delete:') && ( - + {deleteOperation.state?.phase === 'failed' && ( + )} )} @@ -428,7 +437,7 @@ function RunDetailProblemBanner({ onRetry }: { problem: RunDetailProblem; - onRetry: () => void; + onRetry?: () => void; }) { const { t } = useI18n(); return ( @@ -437,20 +446,24 @@ function RunDetailProblemBanner({ diagnostic={problem.diagnostic ? formatProblemDiagnostic(problem) : null} diagnosticLabel={t('problemDiagnostics')} actions={ - <> - {problem.code === 'history_unavailable' && ( - - {t('historyOpenInstall')} - - )} - - + problem.code === 'history_unavailable' || onRetry ? ( + <> + {problem.code === 'history_unavailable' && ( + + {t('historyOpenInstall')} + + )} + {onRetry && ( + + )} + + ) : undefined } /> ); From 1c59d00f44e0747d91770cd10b18c6e861ac054c Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 03:02:18 +0800 Subject: [PATCH 2/2] docs: record destructive operation semantics --- CONTEXT.md | 9 +++++---- docs/INDEX.md | 14 ++++++++------ docs/truth/architecture.md | 7 ++++--- docs/truth/frontend.md | 11 ++++++----- docs/truth/history-stream.md | 18 +++++++++--------- docs/truth/install-reset.md | 8 ++++---- docs/truth/verification.md | 9 +++++---- 7 files changed, 41 insertions(+), 35 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index da70902b..7d3cb000 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,7 +1,7 @@ --- status: truth topic: context -last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 +last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b --- # BazaarPlusPlus Installer Context @@ -27,11 +27,12 @@ Current behavior truth lives under `docs/truth/` (topic-sliced, code-cited, hash - **InstallState** — the frontend/backend contract for the install page: paths, game/mod state, compat state, action gates, and semantic warning codes plus parameters (`src-tauri/src/services/install/types.rs:5-20`, `src-tauri/src/services/install/types.rs:73-85`). - **Selected game installation** — the one session-scoped The Bazaar installation shared by Install, History, and Stream. Valid explicit paths update it; resolution then uses explicit, selected, startup-detected, and fallback priority. It is held only in managed memory and is recreated empty on app restart (`src-tauri/src/services/selected_game_installation.rs:14-115`, `src-tauri/src/lib.rs:38-41`). - **Reset (local data)** — the only flow that deletes the mod's `BazaarPlusPlusV4/` data directory; explicit, confirmed, refused while the game runs, and performed under exclusive stream-runtime maintenance (`src-tauri/src/services/bepinex/mod.rs:20-62`, `src-tauri/src/stream/runtime.rs:100-108`). Uninstall never touches it. -- **History** — the facade around the Selected game installation's mod-owned SQLite database, including reads, detail, reveal, video deletion, and storage cleanup (`src-tauri/src/services/history.rs:48-270`); the database is created and primarily written by the mod. -- **Semantic problem** — a command failure contract made of a stable code, string parameters, and an optional troubleshooting diagnostic (`src-tauri/src/problem.rs:3-42`). History publishes unavailable/read/action codes; Install publishes detection/action/game-running/partial-failure codes; Stream publishes service/window/crop capability codes at the native boundary and adds polling/clipboard/opener codes in its frontend workflow (`src-tauri/src/services/history.rs:74-188`, `src-tauri/src/services/install/mod.rs:200-235`, `src-tauri/src/commands/stream.rs:12-110`, `src/features/stream/streamProblems.ts:7-105`). Presenters localize these codes without using the diagnostic as user copy, while the native adapter preserves the structured payload (`src/api/problems.ts:3-49`, `src/api/nativeCommands.ts:5-15`). +- **History** — the facade around the Selected game installation's mod-owned SQLite database, including reads, detail, reveal, video deletion, and storage cleanup (`src-tauri/src/services/history.rs:48-288`); the database is created and primarily written by the mod. +- **Semantic problem** — a command failure contract made of a stable code, string parameters, and an optional troubleshooting diagnostic (`src-tauri/src/problem.rs:3-42`). History publishes unavailable/read/action codes, including cleanup preview/execute operation parameters; Install publishes detection/action/game-running/partial-failure codes; Stream publishes service/window/crop capability codes at the native boundary and adds polling/clipboard/opener codes in its frontend workflow (`src-tauri/src/services/history.rs:74-188`, `src-tauri/src/services/history.rs:258-274`, `src-tauri/src/services/install/mod.rs:200-235`, `src-tauri/src/commands/stream.rs:12-110`, `src/features/stream/streamProblems.ts:7-105`). Presenters localize these codes without using the diagnostic as user copy, while the native adapter preserves the structured payload (`src/api/problems.ts:3-49`, `src/api/nativeCommands.ts:5-15`). +- **Confirmed operation** — the shared frontend lifecycle for a target-bearing destructive action: confirming, non-dismissible running, retained failure with retry/safe exit, and success-only closure. It refuses conflicting requests and repeated submission in `src/features/shared/confirmedOperation.ts:3-94`; cleanup, reset, and video deletion supply their actual targets and semantic problems. - **Stream runtime / overlay** — the single serialized owner of the local Axum service lifecycle, window selection, and exclusive maintenance; the production service remains on `127.0.0.1:17654` and serves the OBS overlay and settings pages (`src-tauri/src/stream/runtime.rs:43-108`, `src-tauri/src/stream/server.rs:16-69`). - **Stream workflow** — the framework-neutral frontend owner of independent service, polling freshness, window, crop, and one-off action capabilities. It keeps semantic state and derives one snapshot; browser/Tauri concerns enter through injected ports, while React creates the workflow once and only attaches lifecycle and subscription (`src/features/stream/streamWorkflow.ts:53-122`, `src/features/stream/streamWorkflow.ts:185-320`, `src/features/stream/streamWorkflow.ts:640-733`, `src/features/stream/useStreamPage.ts:20-42`). -- **Storage cleanup** — preset-driven deletion of old screenshots and run data with upload-safety and referenced-file protections; its IPC is the two scope-tagged preview/execute operations (`src-tauri/src/commands/history.rs:61-79`, `src-tauri/src/services/history.rs:25-44`). +- **Storage cleanup** — preset-driven deletion of old screenshots and run data with upload-safety and referenced-file protections; its IPC is the two scope-tagged, semantic-problem preview/execute operations (`src-tauri/src/commands/history.rs:61-79`, `src-tauri/src/services/history.rs:25-44`, `src-tauri/src/services/history.rs:337-353`). - **Generated bindings** — `src/types/generated/commands.ts`, emitted by `npm run generate:bindings` from the same Specta builder that registers the Tauri invoke handler; never hand-edited (`src-tauri/src/commands/registry.rs:3-50`, `scripts/generate-bindings.mjs:85-123`). ## Current Topics diff --git a/docs/INDEX.md b/docs/INDEX.md index 05dbdbdd..95acdeb4 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -18,6 +18,8 @@ Install page-state citation refresh: `2026-07-19` on `f23d786ab3bf1998f556f5fe05 Stream capability citation refresh: `2026-07-19` on `5bbe32c870bc06e35e5064f3c8403ff22b359d32` — the context glossary plus architecture, frontend, History/Stream, and verification topics were checked against the independent Stream capability states, stale polling contract, semantic problem presentation, and locale-stable workflow lifecycle. +Destructive-operation citation refresh: `2026-07-19` on `0f609de844c0cbc48e7fb53396a90d5f32776c2b` — the context glossary plus architecture, frontend, Install/Reset, History/Stream, and verification topics were checked against the target-bearing confirmation lifecycle, explicit active dismissal policies, retained semantic failures, and cleanup semantic native contract. + ## Current Manifest | Path | Topic | Status | Last verified | @@ -27,14 +29,14 @@ Stream capability citation refresh: `2026-07-19` on `5bbe32c870bc06e35e5064f3c84 | `README.md` | project entrypoint | current-entrypoint | 2026-07-11 | | `.trae/rules/git-commit-message.md` | ignored local rule | ignored-operational | n/a | | `docs/INDEX.md` | documentation manifest | manifest | 2026-07-19 | -| `CONTEXT.md` | entry map + glossary | truth | `5bbe32c870bc06e35e5064f3c8403ff22b359d32` | -| `docs/truth/architecture.md` | architecture | truth | `5bbe32c870bc06e35e5064f3c8403ff22b359d32` | -| `docs/truth/frontend.md` | frontend | truth | `5bbe32c870bc06e35e5064f3c8403ff22b359d32` | -| `docs/truth/install-reset.md` | install-reset | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | +| `CONTEXT.md` | entry map + glossary | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | +| `docs/truth/architecture.md` | architecture | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | +| `docs/truth/frontend.md` | frontend | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | +| `docs/truth/install-reset.md` | install-reset | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | | `docs/truth/launch-modes.md` | launch-modes | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | -| `docs/truth/history-stream.md` | history-stream | truth | `5bbe32c870bc06e35e5064f3c8403ff22b359d32` | +| `docs/truth/history-stream.md` | history-stream | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | | `docs/truth/updater-release.md` | updater-release | truth | `45764680a4476063a46a92f4606dd520f0ce29ef` | -| `docs/truth/verification.md` | verification | truth | `5bbe32c870bc06e35e5064f3c8403ff22b359d32` | +| `docs/truth/verification.md` | verification | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | | `docs/plans/manual-validation.md` | manual-validation | active-plan | `7500016b1c4adfc7b5d0206c7def0ceabae514d5` | | `docs/agents/issue-tracker.md` | agent skills: issue tracker | operational | 2026-07-11 | | `docs/agents/triage-labels.md` | agent skills: triage labels | operational | 2026-07-11 | diff --git a/docs/truth/architecture.md b/docs/truth/architecture.md index 7e214a56..13678ef3 100644 --- a/docs/truth/architecture.md +++ b/docs/truth/architecture.md @@ -1,7 +1,7 @@ --- status: truth topic: architecture -last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 +last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b --- # Architecture @@ -24,12 +24,13 @@ last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 - Install state is produced by Rust detection and serialized through `InstallState`; the contract includes selected paths, game/mod state, macOS compatibility state, action gates, resettable-data and BepInEx-folder status in `src-tauri/src/services/install/types.rs:5-20`, while warnings use typed codes plus parameters in `src-tauri/src/services/install/types.rs:73-85`. - The complete install operation owns payload and launch-mode fact gathering, private planning, ordered production effects, first-error propagation, and a final state refresh in `src-tauri/src/services/install/operation.rs:17-130`; the Tauri command only constructs the request and invokes that operation in `src-tauri/src/commands/install.rs:44-59`. Reset, uninstall, and Steam-only launch remain in the install service facade. - All Install command failures share `SemanticProblem`; detection and action boundaries classify stable codes and operation/recovery parameters in `src-tauri/src/commands/install.rs:15-99` and `src-tauri/src/services/install/mod.rs:200-235`. The frontend keeps Install page state and its sole primary-action derivation framework-independent in `src/features/install/installPageState.ts:5-132`. -- The History facade resolves Selected game installation and privately owns storage derivation, reads, reveals, deletes, and cleanup dispatch in `src-tauri/src/services/history.rs:48-270`; commands expose ids plus domain cleanup scope/preset without raw paths or cutoffs in `src-tauri/src/commands/history.rs:7-79`. Reads use SQLite read-only connections by default, while mutation uses separate write connections in `src-tauri/src/history/queries.rs:29-54`. -- History list/detail/reveal/delete commands use `SemanticProblem`; the shared Rust DTO fixes code/parameter/diagnostic shape, the detail command models not-found as a successful `Option`, and the facade classifies unavailable selection, failed reads, and failed actions before the command boundary in `src-tauri/src/problem.rs:3-38`, `src-tauri/src/services/history.rs:74-188`, and `src-tauri/src/commands/history.rs:7-49`. +- The History facade resolves Selected game installation and privately owns storage derivation, reads, reveals, deletes, and cleanup dispatch in `src-tauri/src/services/history.rs:48-288`; commands expose ids plus domain cleanup scope/preset without raw paths or cutoffs in `src-tauri/src/commands/history.rs:7-79`. Reads use SQLite read-only connections by default, while mutation uses separate write connections in `src-tauri/src/history/queries.rs:29-54`. +- History list/detail/reveal/delete/cleanup commands use `SemanticProblem`; the shared Rust DTO fixes code/parameter/diagnostic shape, the detail command models not-found as a successful `Option`, and the facade classifies unavailable selection, failed reads, and failed actions before the command boundary in `src-tauri/src/problem.rs:3-38`, `src-tauri/src/services/history.rs:74-188`, `src-tauri/src/services/history.rs:258-274`, and `src-tauri/src/commands/history.rs:7-79`. - History internals default to private modules; only cleanup algorithms and mapper/screenshots test seams are crate-visible, and the facade receives a narrowed repository surface in `src-tauri/src/history/mod.rs:1-13`. - `StreamRuntime` is the only stream lifecycle mutation boundary: it serializes ensure/restart/stop/window/maintenance operations and privately owns the task plus captured installation paths in `src-tauri/src/stream/runtime.rs:43-108` and `src-tauri/src/stream/runtime.rs:188-280`. Its private production adapter binds the local Axum service to `127.0.0.1:17654` in `src-tauri/src/stream/server.rs:16-69`. - Stream Tauri commands map service, window, and crop failures into the shared semantic problem contract before crossing IPC in `src-tauri/src/commands/stream.rs:12-110`; stable codes are part of the generated `SemanticProblemCode` union from `src-tauri/src/problem.rs:3-42`. - The frontend Stream workflow depends inward on command, scheduler, clipboard, and opener ports and exposes capability-oriented semantic snapshots in `src/features/stream/streamWorkflow.ts:25-122`. It owns response ordering and capability gates in `src/features/stream/streamWorkflow.ts:185-320` and `src/features/stream/streamWorkflow.ts:640-733`; the React hook provides outer adapters and only subscribes, starts, and disposes one locale-independent workflow in `src/features/stream/useStreamPage.ts:9-42`. +- Frontend destructive confirmation state is centralized in a framework-neutral external-store controller rather than page-local booleans. It owns target retention, single-flight execution, success-only closure, and semantic failure retention in `src/features/shared/confirmedOperation.ts:3-94`; React only memoizes and subscribes to the controller at `src/features/shared/confirmedOperation.ts:82-94`. ## Build And Generated Artifacts diff --git a/docs/truth/frontend.md b/docs/truth/frontend.md index 4418c20a..3b71b206 100644 --- a/docs/truth/frontend.md +++ b/docs/truth/frontend.md @@ -1,7 +1,7 @@ --- status: truth topic: frontend -last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 +last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b --- # Frontend @@ -18,7 +18,7 @@ last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 - `.selectable` and `.user-content` opt content back into text selection in `src/styles/index.css:84-89`. - Reduced motion is honored through `prefers-reduced-motion` in `src/styles/index.css:91-99`. - App modals use the native `` wrapper and top-layer dialog styling; dialog CSS is in `src/styles/index.css:101-128`, and the updater modal consumes the shared `Dialog` component in `src/layouts/ShellUpdateModal.tsx:40-45`. -- The five install, reset, cleanup, and video-delete confirmation flows share the `Dialog`-composing `ConfirmDialog`, which owns tone-specific chrome, acknowledgement gating, busy affordances, and an explicit dismiss gate in `src/components/ui/ConfirmDialog.tsx:43-180`. Run Detail enables that gate while native video deletion is in flight, so Escape, backdrop, close, and cancel do not present the operation as cancellable in `src/pages/RunDetail.tsx:248-268`. +- Install, reset, cleanup, and video-delete confirmations share the `Dialog`-composing `ConfirmDialog`. It requires an explicit active dismissal policy for Escape, backdrop, close, and secondary actions; blocked work removes the secondary cancel affordance, disables close, and says that the operation cannot be cancelled in `src/components/ui/ConfirmDialog.tsx:14-62` and `src/components/ui/ConfirmDialog.tsx:98-242`. Current native destructive commands all use the blocked policy because none exposes a cancellation contract (`src/pages/Install.tsx:143-176`, `src/features/history/StorageCleanupCard.tsx:124-171`, `src/pages/RunDetail.tsx:246-277`). - The current Tauri security config has `csp: null` in `src-tauri/tauri.conf.json:23-25`; treat any CSP hardening claim as future work until code changes. ## Runtime Seam @@ -28,6 +28,7 @@ last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 - Shared install, stream, crop, history, cleanup, and bootstrap preview values live in the leaf module `src/api/previewDefaults.ts`; Preview reuses those object references in `src/api/previewCommands.ts:16-50` so polling preserves React state bailouts. - Stream commands pass through one semantic port over the selected native or Preview command adapter in `src/features/stream/streamApi.ts:7-36`. The framework-neutral workflow owns replayable initialization, polling freshness and response epochs, capability-scoped operations/problems, semantic notices, and the derived page snapshot in `src/features/stream/streamWorkflow.ts:185-320` and `src/features/stream/streamWorkflow.ts:509-733`; `useStreamPage` supplies browser ports and creates the workflow once, independently of locale, before binding its lifecycle to React in `src/features/stream/useStreamPage.ts:9-42`. - The shared page-state seam is a discriminated union of initial loading, blocking failure, ready-empty, and ready-content with nested idle/refreshing/failed refresh state; request ids reject stale completions in `src/features/shared/pageState.ts:1-60`. Shared UI problems retain code, parameters, and optional diagnostics separately from localized copy in `src/features/shared/problems.ts:4-40` and `src/components/ui/ProblemBanner.tsx:3-43`. +- Destructive workflows use a framework-neutral confirmed-operation controller that keeps the target across confirming/running/failure, rejects conflicting requests and repeat execution, blocks dismissal while running, closes only after success, and retains a semantic problem for retry or safe exit after failure in `src/features/shared/confirmedOperation.ts:3-94`. - Run Detail specializes that seam with a distinct not-found state, preserved ready content on refresh failure, and a separate action state that globally gates conflicting work while retaining target-scoped failures in `src/features/history/runDetailPageState.ts:5-167`. Its semantic problem presenter maps stable backend codes and operation parameters to localized copy without using diagnostics as user-facing text in `src/features/history/runDetailProblems.ts:9-62`. ## Current Product Surfaces @@ -36,13 +37,13 @@ last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 - Install facts currently show only BazaarPlusPlus, not the broader fact list from the historical design spec, in `src/features/install/InstallActionsPanel.tsx:58-65`. - Install renders exactly one primary action. Its choose/install/repair/launch mode, gate, and loading state are derived from one view model based on path validity, install/version state, compatibility consistency, and the active operation in `src/features/install/installPageState.ts:75-132` and `src/features/install/InstallActionsPanel.tsx:141-222`. - Install warnings and failures are presented from stable semantic codes in bilingual frontend copy; native diagnostics are kept in the diagnostic disclosure rather than used as the message in `src/features/install/installProblems.ts:23-107` and `src/features/install/InstallProblemBanner.tsx:1-32`. -- The reset-local-data button is disabled unless backend action gates allow reset data, and its label switches to a no-data message when the game path is valid but no resettable data exists in `src/features/install/InstallActionsPanel.tsx:103-115`. +- The reset-local-data button is disabled unless backend action gates allow reset data, and its label switches to a no-data message when the game path is valid but no resettable data exists in `src/features/install/InstallActionsPanel.tsx:103-115`. Both reset confirmations retain the selected game path as their target; failure keeps the modal, semantic problem, acknowledgement, and partial-failure paths available for retry or safe close, while success alone closes and refreshes state in `src/pages/Install.tsx:53-84` and `src/pages/Install.tsx:143-176`. - History renders loading, blocking failure, and the two successful list states as mutually exclusive branches; refresh failures remain inside the ready branch and keep prior data in `src/pages/History.tsx:42-97` and `src/features/shared/pageState.ts:43-54`. - History summary cards are Runs, Videos, and Win Rate in `src/pages/History.tsx:50-64`. - History rows link to details, show lazy-decoded preview images with an error fallback, and display hero, locale-formatted date, result, progress, rank, and rating in `src/pages/History.tsx:125-230` and `src/features/history/format.ts:4-27`. - History list loading calls `listHistoryRuns` independently from status-only Stream preview discovery; stopped or failed Stream status produces a thumbnail-only problem and never rejects the list request in `src/features/history/useHistoryPage.ts:37-64` and `src/features/history/historyPreview.ts:14-45`. -- Run detail renders explicit initial-loading, not-found, blocking-failure, and ready branches, preserving ready content behind a localized refresh-failure banner in `src/pages/RunDetail.tsx:66-130`. Screenshot, video, delete, and refresh controls share one action gate, failures retry beside their screenshot or battle target, and replay duration/size use locale-aware formatters in `src/pages/RunDetail.tsx:160-181`, `src/pages/RunDetail.tsx:297-421`, and `src/features/history/format.ts:113-150`. -- Storage cleanup submits only generated `StorageCleanupScope` plus `StorageCleanupPreset`, retains the tagged preview/execution result, and narrows on `scope` when rendering screenshot versus run-data copy in `src/features/history/useStorageCleanup.ts:1-59` and `src/features/history/StorageCleanupCard.tsx:32-77`. +- Run detail renders explicit initial-loading, not-found, blocking-failure, and ready branches, preserving ready content behind a localized refresh-failure banner in `src/pages/RunDetail.tsx:66-130`. Screenshot, video, delete, and refresh controls share one action gate; video deletion keeps its battle/video target visible, blocks dismissal while running, retains localized semantic failure for retry/close, and closes only after the returned detail replaces page data in `src/features/history/useRunDetailPage.ts:84-153` and `src/pages/RunDetail.tsx:246-277`. +- Storage cleanup submits only generated `StorageCleanupScope` plus `StorageCleanupPreset`. Preview and execute are separately single-flight; the selected scope, preset, counts, and consequence remain in the confirmed-operation target through running/failure, and success refreshes History before publishing the outcome in `src/features/history/useStorageCleanup.ts:23-78` and `src/features/history/StorageCleanupCard.tsx:124-171`. Cleanup failures are localized from semantic problem codes/operation parameters while diagnostics remain separate in `src/features/history/storageCleanupProblems.ts:9-52`. - Stream renders capability-localized service, polling, display-window, crop, and one-off action problems beside the controls that can recover them; diagnostics remain in the optional disclosure rather than becoming user copy in `src/pages/Stream.tsx:34-137`, `src/pages/Stream.tsx:182-319`, and `src/pages/Stream.tsx:324-358`. - Stream status, database, window, and notice copy is derived from the current translator at render time. Stale running/stopped values have distinct presentation and are not presented as authoritative in `src/features/stream/streamPresentation.ts:20-80`. diff --git a/docs/truth/history-stream.md b/docs/truth/history-stream.md index 84d7f8d2..dbb8da4f 100644 --- a/docs/truth/history-stream.md +++ b/docs/truth/history-stream.md @@ -1,17 +1,17 @@ --- status: truth topic: history-stream -last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 +last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b --- # History And Stream ## History Data Access -- The History facade resolves the current Selected game installation, privately derives database/game/video storage paths, and owns list, detail, reveal, delete, and cleanup operations in `src-tauri/src/services/history.rs:48-270`; it does not borrow Stream runtime state. -- List/detail resolution emits `history_unavailable`, while SQLite/query failures emit `history_read_failed` with a stable operation parameter and optional diagnostic in `src-tauri/src/services/history.rs:74-102`. Screenshot reveal, video reveal, and video delete failures emit `history_action_failed` with their target operation in `src-tauri/src/services/history.rs:109-188` and `src-tauri/src/services/history.rs:343-347`. Their shared serialized contract is defined in `src-tauri/src/problem.rs:3-38` and crosses the Tauri command boundary at `src-tauri/src/commands/history.rs:7-49`. +- The History facade resolves the current Selected game installation, privately derives database/game/video storage paths, and owns list, detail, reveal, delete, and cleanup operations in `src-tauri/src/services/history.rs:48-288`; it does not borrow Stream runtime state. +- List/detail resolution emits `history_unavailable`, while SQLite/query failures emit `history_read_failed` with a stable operation parameter and optional diagnostic in `src-tauri/src/services/history.rs:74-102`. Screenshot reveal, video reveal, and video delete failures emit `history_action_failed` with their target operation in `src-tauri/src/services/history.rs:109-188` and `src-tauri/src/services/history.rs:363-366`. Their shared serialized contract is defined in `src-tauri/src/problem.rs:3-38` and crosses the Tauri command boundary at `src-tauri/src/commands/history.rs:7-49`. - `get_history_run_detail` returns a successful nullable detail, so an absent run is distinct from an unavailable installation or failed read in `src-tauri/src/commands/history.rs:16-23` and `src-tauri/src/services/history.rs:93-107`. -- Tauri History commands pass only domain inputs such as ids, limits, cleanup scope, and preset to the facade; command signatures contain no database, game, video-directory, cutoff, or cleanup-plan values in `src-tauri/src/commands/history.rs:7-79`. +- Tauri History commands pass only domain inputs such as ids, limits, cleanup scope, and preset to the facade; command signatures contain no database, game, video-directory, cutoff, or cleanup-plan values in `src-tauri/src/commands/history.rs:7-79`. Cleanup preview/execute now return `SemanticProblem`, with unavailable selection classified separately and native failures carrying stable `preview_storage_cleanup` or `execute_storage_cleanup` operation parameters in `src-tauri/src/services/history.rs:258-274` and `src-tauri/src/services/history.rs:337-366`. - History reads open the BazaarPlusPlus SQLite database read-only with a two-second busy timeout in `src-tauri/src/history/queries.rs:29-35`. - Write access is separate and uses `SQLITE_OPEN_READ_WRITE` in `src-tauri/src/history/queries.rs:37-43`. - History summary counts runs, completed runs, wins, latest run timestamp, and completed combat replay videos in `src-tauri/src/history/queries.rs:73-109`. @@ -25,14 +25,14 @@ last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 - The History page renders Runs, Videos, and Win Rate summary cards in `src/pages/History.tsx:50-64`. - History rows include optional preview images and link to `/history/:run_id` details in `src/pages/History.tsx:125-230`. - Run detail makes initial loading, not-found, blocking failure, and ready content exclusive; refresh failure retains the last successful detail and stale completions are ignored in `src/features/history/runDetailPageState.ts:5-90` and `src/pages/RunDetail.tsx:66-130`. -- Detail refresh, screenshot reveal, video reveal, and video deletion share one visible single-flight gate. Action failures stay scoped to the screenshot or affected battle and clear when that target retries in `src/features/history/runDetailPageState.ts:93-167`, `src/features/history/useRunDetailPage.ts:42-161`, and `src/pages/RunDetail.tsx:160-181`, `src/pages/RunDetail.tsx:297-421`. -- Run detail formats dates, replay durations, and replay sizes through shared locale-aware helpers in `src/features/history/format.ts:4-61` and `src/features/history/format.ts:113-150`, with replay metadata rendered beside each video action in `src/pages/RunDetail.tsx:356-400`. -- The History page renders the storage cleanup card after the summary cards only in a successful ready state in `src/pages/History.tsx:50-67`; the card offers separate end-of-run screenshot and run-data rows in `src/features/history/StorageCleanupCard.tsx:80-115`, with its confirmation composed inline at `src/features/history/StorageCleanupCard.tsx:117-139`. +- Detail refresh, screenshot reveal, video reveal, and video deletion share one visible single-flight gate. Action failures stay scoped to the screenshot or affected battle and clear when that target retries in `src/features/history/runDetailPageState.ts:93-167`, `src/features/history/useRunDetailPage.ts:46-165`, `src/pages/RunDetail.tsx:160-181`, and `src/pages/RunDetail.tsx:306-432`. +- Run detail formats dates, replay durations, and replay sizes through shared locale-aware helpers in `src/features/history/format.ts:4-61` and `src/features/history/format.ts:113-150`, with replay metadata rendered beside each video action in `src/pages/RunDetail.tsx:365-409`. +- The History page renders the storage cleanup card after the summary cards only in a successful ready state in `src/pages/History.tsx:50-67`; the card offers separate end-of-run screenshot and run-data rows, then confirms the exact scope, preset, counts, and consequence. Execute failure keeps that target and localized semantic problem in place for retry or safe close; success alone closes and refreshes History in `src/features/history/StorageCleanupCard.tsx:84-171` and `src/features/history/useStorageCleanup.ts:23-78`. ## Storage Cleanup -- Tauri exposes only `preview_storage_cleanup(scope, preset)` and `execute_storage_cleanup(scope, preset)` in `src-tauri/src/commands/history.rs:61-79`. Their Specta-generated results are Serde scope-tagged unions for `screenshots` and `run_data` in `src-tauri/src/services/history.rs:25-44`. -- The facade derives the same preset cutoff for preview and execute before dispatching on scope in `src-tauri/src/services/history.rs:200-256`; its tempfile-backed behavior test covers list/detail/reveal/delete and both cleanup scopes against real SQLite rows and files in `src-tauri/src/services/history.rs:619-725`. +- Tauri exposes only `preview_storage_cleanup(scope, preset)` and `execute_storage_cleanup(scope, preset)` in `src-tauri/src/commands/history.rs:61-79`. Their Specta-generated success results are Serde scope-tagged unions for `screenshots` and `run_data` in `src-tauri/src/services/history.rs:25-44`; failures use the shared semantic problem contract. +- The facade derives the same preset cutoff for preview and execute before dispatching on scope in `src-tauri/src/services/history.rs:200-256`; its tempfile-backed behavior test covers list/detail/reveal/delete and both cleanup scopes against real SQLite rows and files in `src-tauri/src/services/history.rs:675-781`. - The cleanup presets are the wire strings `all`, `older_than_7_days`, and `before_this_month`; `CleanupCutoff::for_preset` computes non-`all` cutoffs from local time and stores UTC strings for SQL comparisons in `src-tauri/src/history/cleanup.rs:9-71`. A non-`all` preset never collapses to `None` (the wire meaning of `all`): a spring-forward DST gap at local month-start falls back to local noon. - Screenshot cleanup plans and executes against `end_of_run_auto` rows, skips pending BazaarDB screenshot uploads, compares captured timestamps with `datetime()`, protects files still referenced by surviving rows, and sweeps orphan dated-folder files plus stale `UploadCache` copies in `src-tauri/src/history/cleanup.rs:123-174` and `src-tauri/src/history/cleanup.rs:187-255`. - The orphan sweep skips any folder whose local date is at or after `min(cutoff_date, today_local_date)`, so today's local-date folder is always protected — even under preset `all`, where there is no cutoff — because the mod writes a screenshot's PNG (through an atomic `.png..tmp` rename) before it inserts the matching `run_screenshots` row, and an in-flight, not-yet-rowed file would otherwise be swept. `today_local_date` is derived from the same `chrono::Local::now()` that builds the cutoff in `src-tauri/src/services/history.rs:200-256` and threaded into `scan_orphan_screenshot_files` at `src-tauri/src/history/cleanup.rs:992-1056` (a capture straddling local midnight into yesterday's folder is a known, unclosed sub-second window). diff --git a/docs/truth/install-reset.md b/docs/truth/install-reset.md index d1d842db..5fda02c0 100644 --- a/docs/truth/install-reset.md +++ b/docs/truth/install-reset.md @@ -1,7 +1,7 @@ --- status: truth topic: install-reset -last-verified: f23d786ab3bf1998f556f5fe05b6e47467a7ea48 +last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b --- # Install And Reset @@ -28,9 +28,9 @@ last-verified: f23d786ab3bf1998f556f5fe05b6e47467a7ea48 ## Reset Local Data -- The frontend opens a dedicated reset confirmation modal and requires an acknowledgement checkbox before confirming in `src/pages/Install.tsx:120-128` and `src/features/install/ResetDataConfirmModal.tsx:21-55`. +- The frontend opens target-bearing reset confirmations and requires an acknowledgement checkbox before confirming. Both show the selected game path and concrete consequence; once the native operation starts, Escape, backdrop, close, and secondary dismissal are blocked because neither reset command is cancellable in `src/pages/Install.tsx:53-84`, `src/features/install/ResetDataConfirmModal.tsx:29-74`, and `src/features/install/ResetBepinexConfirmModal.tsx:26-69`. - The reset button is disabled when reset is not allowed, and the UI distinguishes "no resettable data" from the destructive action label in `src/features/install/InstallActionsPanel.tsx:103-115`. -- `useInstallPage` treats an already-empty state as a no-op, calls `resetBppData`, replaces the completed state from the typed result, and chooses success versus no-op copy from `removed_data` in `src/features/install/useInstallPage.ts:148-166`. +- `useInstallPage` returns a semantic success/problem outcome for every action; reset data treats an already-empty state as a no-op, otherwise calls `resetBppData`, replaces the completed state from the typed result, and chooses success versus no-op copy from `removed_data` in `src/features/install/useInstallPage.ts:103-147` and `src/features/install/useInstallPage.ts:172-190`. The confirmation closes only on that success outcome; failure retains its exact target, semantic problem, and partial-failure paths for retry or safe close in `src/pages/Install.tsx:67-84` and `src/pages/Install.tsx:143-176`. - The Rust reset path enters `StreamRuntime` exclusive maintenance, stops and awaits the stream task, and keeps lifecycle operations excluded throughout blocking deletion in `src-tauri/src/services/bepinex/mod.rs:29-42` and `src-tauri/src/stream/runtime.rs:100-108`. - Reset refuses to run while The Bazaar is detected as running, records whether the data directory existed before cleanup, and returns stable error-code prefixes for blocked or partial-failure cases in `src-tauri/src/services/bepinex/mod.rs:20-27` and `src-tauri/src/services/bepinex/mod.rs:44-70`. -- Raw reset sentinels remain private to the Rust service boundary; the frontend consumes only semantic problem parameters, maps them to localized messages, and extracts partial-failure paths in `src/features/install/installProblems.ts:23-107` and `src/features/install/useInstallPage.ts:100-123`. +- Raw reset sentinels remain private to the Rust service boundary; the frontend consumes only semantic problem parameters, maps them to localized messages, and extracts partial-failure paths in `src/features/install/installProblems.ts:23-107` and `src/features/install/useInstallPage.ts:103-145`. diff --git a/docs/truth/verification.md b/docs/truth/verification.md index 3f065f18..b9c1fa0d 100644 --- a/docs/truth/verification.md +++ b/docs/truth/verification.md @@ -1,7 +1,7 @@ --- status: truth topic: verification -last-verified: 5bbe32c870bc06e35e5064f3c8403ff22b359d32 +last-verified: 0f609de844c0cbc48e7fb53396a90d5f32776c2b --- # Verification @@ -37,12 +37,13 @@ Use the smallest command that verifies the changed behavior; use the authoritati - Selected installation priority and invalid-explicit-path preservation are tested without Tauri state in `src-tauri/src/services/selected_game_installation.rs:138-231`. - The complete install operation tests payload classification, fresh/changed installs, current-install no-op, launch-mode-only repair, production ordering, first-error truncation, and refreshed outcomes through its private effect boundary in `src-tauri/src/services/install/operation.rs:165-289`. - Stream runtime tests exercise concurrent ensure, lifecycle transitions, failed start, and exclusive maintenance blocking in `src-tauri/src/stream/runtime.rs:440-572`. -- The History facade's tempfile test uses a real SQLite schema and managed files across queries, reveal/delete, and both cleanup scopes in `src-tauri/src/services/history.rs:619-725`. +- The History facade's tempfile tests classify cleanup preview/execute failures into semantic operation parameters and use a real SQLite schema plus managed files across queries, reveal/delete, and both cleanup scopes in `src-tauri/src/services/history.rs:621-659` and `src-tauri/src/services/history.rs:675-781`. - Focused Stream capability tests cover independently completing initialization, crop degradation without global failure, stale polling and recovery, scoped operations/problems, bilingual problem presentation, and locale changes without workflow restart in `src/features/stream/streamCapabilityState.test.ts:109-249`. Workflow lifecycle tests cover poll response ordering (including older failures), restart invalidation, authoritative runtime errors, semantic notices, target-scoped one-off failures, disposal/replay, and both command adapters in `src/features/stream/streamWorkflow.test.ts:116-307`. - Stream native semantic classification is pinned at the command boundary in `src-tauri/src/commands/stream.rs:94-139`, and native adapter preservation is covered in `src/api/commandClient.dispatch.test.ts:78-96`. -- Semantic-problem serialization plus History list/detail/action classification are covered at the Rust boundary in `src-tauri/src/problem.rs:42-84` and `src-tauri/src/services/history.rs:526-602`; the native adapter preservation path is covered in `src/api/commandClient.dispatch.test.ts:39-55`. +- Semantic-problem serialization plus History list/detail/action classification are covered at the Rust boundary in `src-tauri/src/problem.rs:42-84` and `src-tauri/src/services/history.rs:546-622`; the native adapter preservation path is covered in `src/api/commandClient.dispatch.test.ts:39-55`. - Focused History tests cover exclusive empty/error/content transitions, refresh-data preservation, stale completions, stopped/failed preview capability, bilingual problem presentation, and locale-aware dates in `src/features/history/historyPageState.test.ts:46-108`, `src/features/history/historyPreview.test.ts:5-49`, `src/features/history/historyProblems.test.ts:17-30`, and `src/features/history/format.test.ts:4-19`. -- Run Detail tests cover its four page states, preserved refresh failure, stale completion, global action gate, target-scoped retry, and bilingual semantic problem presentation in `src/features/history/runDetailPageState.test.ts:56-187` and `src/features/history/runDetailProblems.test.ts:6-39`. The shared confirmation test verifies all dismiss controls can be visibly disabled for an uncancellable action in `src/components/ui/ConfirmDialog.test.tsx:98-101`. +- Run Detail tests cover its four page states, preserved refresh failure, stale completion, global action gate, target-scoped retry, and bilingual semantic problem presentation in `src/features/history/runDetailPageState.test.ts:56-187` and `src/features/history/runDetailProblems.test.ts:6-39`. Shared confirmation tests cover idle and active Escape/backdrop/close/secondary behavior for blocked, detachable, and genuinely cancelable policies in `src/components/ui/ConfirmDialog.test.tsx:104-205`; the confirmed-operation tests cover blocked dismissal, repeat submission, success-only closure, retained failure, retry, and cleanup/reset/delete targets in `src/features/shared/confirmedOperation.test.ts:15-96`. +- Cleanup semantic presentation and native-adapter preservation are covered in `src/features/history/storageCleanupProblems.test.ts:8-30` and `src/api/commandClient.dispatch.test.ts:97-118`; neither test treats diagnostics as user-facing copy. - Install tests cover explicit initial detection, preserved refresh failure and retry, each primary-action branch, shared disabled/loading derivation, bilingual semantic warnings/problems, partial-failure recovery parameters, and native-adapter preservation in `src/features/install/installPageState.test.ts:43-228`, `src/features/install/installProblems.test.ts:10-89`, and `src/api/commandClient.dispatch.test.ts:57-76`. Rust tests pin Install semantic serialization and service-boundary classification in `src-tauri/src/problem.rs:46-84` and `src-tauri/src/services/install/mod.rs:282-366`. ## Version And Platform Guards