From d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 02:02:15 +0800 Subject: [PATCH 1/4] feat: model Run Detail states and actions --- src-tauri/src/commands/history.rs | 8 +- src-tauri/src/problem.rs | 1 + src-tauri/src/services/history.rs | 130 ++++++- src/api/commandAdapter.ts | 14 +- src/api/previewCommands.test.ts | 6 +- src/api/previewCommands.ts | 2 +- src/api/problems.ts | 3 +- src/components/ui/ConfirmDialog.test.tsx | 5 + src/components/ui/ConfirmDialog.tsx | 18 +- src/features/history/StorageCleanupCard.tsx | 10 +- src/features/history/format.test.ts | 11 +- src/features/history/format.ts | 66 +++- src/features/history/historyProblems.ts | 29 +- .../history/runDetailPageState.test.ts | 187 ++++++++++ src/features/history/runDetailPageState.ts | 167 +++++++++ .../history/runDetailProblems.test.ts | 39 ++ src/features/history/runDetailProblems.ts | 62 ++++ src/features/history/useHistoryPage.ts | 4 +- src/features/history/useRunDetailPage.ts | 171 +++++++-- src/i18n/messages.ts | 23 ++ src/pages/RunDetail.tsx | 350 ++++++++++++------ src/types/generated/commands.ts | 7 +- 22 files changed, 1113 insertions(+), 200 deletions(-) create mode 100644 src/features/history/runDetailPageState.test.ts create mode 100644 src/features/history/runDetailPageState.ts create mode 100644 src/features/history/runDetailProblems.test.ts create mode 100644 src/features/history/runDetailProblems.ts diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 46ae07d4..826a199f 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -18,13 +18,13 @@ pub fn list_history_runs( pub fn get_history_run_detail( app: tauri::AppHandle, run_id: String, -) -> Result { +) -> Result, SemanticProblem> { history::get_run_detail(&app, &run_id) } #[tauri::command] #[specta::specta] -pub fn reveal_run_screenshot(app: tauri::AppHandle, run_id: String) -> Result<(), String> { +pub fn reveal_run_screenshot(app: tauri::AppHandle, run_id: String) -> Result<(), SemanticProblem> { history::reveal_run_screenshot(&app, &run_id) } @@ -34,7 +34,7 @@ pub fn reveal_battle_video( app: tauri::AppHandle, battle_id: String, video_id: Option, -) -> Result<(), String> { +) -> Result<(), SemanticProblem> { history::reveal_battle_video(&app, &battle_id, video_id.as_deref()) } @@ -44,7 +44,7 @@ pub fn delete_battle_video( app: tauri::AppHandle, battle_id: String, video_id: String, -) -> Result { +) -> Result { history::delete_battle_video(&app, &battle_id, &video_id) } diff --git a/src-tauri/src/problem.rs b/src-tauri/src/problem.rs index 4fa32152..7432839a 100644 --- a/src-tauri/src/problem.rs +++ b/src-tauri/src/problem.rs @@ -12,6 +12,7 @@ pub struct SemanticProblem { pub enum SemanticProblemCode { HistoryUnavailable, HistoryReadFailed, + HistoryActionFailed, } impl SemanticProblem { diff --git a/src-tauri/src/services/history.rs b/src-tauri/src/services/history.rs index 37ac5089..050d9cce 100644 --- a/src-tauri/src/services/history.rs +++ b/src-tauri/src/services/history.rs @@ -71,7 +71,7 @@ impl History { .ok_or_else(|| HISTORY_UNAVAILABLE.to_string()) } - fn from_resolved_game_path_for_list( + fn from_resolved_game_path_for_page( game_path: Option, ) -> Result { Self::from_resolved_game_path(game_path) @@ -90,6 +90,17 @@ impl History { }) } + fn run_detail_for_page( + &self, + run_id: &str, + ) -> Result, SemanticProblem> { + get_history_run_detail(&self.paths.database_path, run_id).map_err(|diagnostic| { + SemanticProblem::new(SemanticProblemCode::HistoryReadFailed) + .with_param("operation", "get_run_detail") + .with_diagnostic(diagnostic) + }) + } + fn run_detail(&self, run_id: &str) -> Result { get_history_run_detail(&self.paths.database_path, run_id)? .ok_or_else(|| format!("History run {run_id} was not found.")) @@ -107,6 +118,15 @@ impl History { revealer.reveal(&path) } + fn reveal_run_screenshot_for_page( + &self, + run_id: &str, + revealer: &impl FileRevealer, + ) -> Result<(), SemanticProblem> { + self.reveal_run_screenshot(run_id, revealer) + .map_err(|diagnostic| history_action_problem("reveal_screenshot", diagnostic)) + } + fn reveal_battle_video( &self, battle_id: &str, @@ -125,6 +145,16 @@ impl History { revealer.reveal(&path) } + fn reveal_battle_video_for_page( + &self, + battle_id: &str, + video_id: Option<&str>, + revealer: &impl FileRevealer, + ) -> Result<(), SemanticProblem> { + self.reveal_battle_video(battle_id, video_id, revealer) + .map_err(|diagnostic| history_action_problem("reveal_video", diagnostic)) + } + fn delete_battle_video( &self, battle_id: &str, @@ -148,6 +178,15 @@ impl History { self.run_detail(&run_id) } + fn delete_battle_video_for_page( + &self, + battle_id: &str, + video_id: &str, + ) -> Result { + self.delete_battle_video(battle_id, video_id) + .map_err(|diagnostic| history_action_problem("delete_video", diagnostic)) + } + fn delete_run_videos(&self, run_id: &str, limit: usize) -> Result { self.require_database_exists()?; delete_run_videos_in_repo( @@ -234,32 +273,39 @@ pub fn list_runs( app: &tauri::AppHandle, limit: Option, ) -> Result { - History::from_resolved_game_path_for_list(History::resolved_game_path(app))? + History::from_resolved_game_path_for_page(History::resolved_game_path(app))? .list_runs_for_page(limit.unwrap_or(50)) } -pub fn get_run_detail(app: &tauri::AppHandle, run_id: &str) -> Result { - History::resolve(app)?.run_detail(run_id) +pub fn get_run_detail( + app: &tauri::AppHandle, + run_id: &str, +) -> Result, SemanticProblem> { + History::from_resolved_game_path_for_page(History::resolved_game_path(app))? + .run_detail_for_page(run_id) } -pub fn reveal_run_screenshot(app: &tauri::AppHandle, run_id: &str) -> Result<(), String> { - History::resolve(app)?.reveal_run_screenshot(run_id, &SystemFileRevealer) +pub fn reveal_run_screenshot(app: &tauri::AppHandle, run_id: &str) -> Result<(), SemanticProblem> { + History::from_resolved_game_path_for_page(History::resolved_game_path(app))? + .reveal_run_screenshot_for_page(run_id, &SystemFileRevealer) } pub fn reveal_battle_video( app: &tauri::AppHandle, battle_id: &str, video_id: Option<&str>, -) -> Result<(), String> { - History::resolve(app)?.reveal_battle_video(battle_id, video_id, &SystemFileRevealer) +) -> Result<(), SemanticProblem> { + History::from_resolved_game_path_for_page(History::resolved_game_path(app))? + .reveal_battle_video_for_page(battle_id, video_id, &SystemFileRevealer) } pub fn delete_battle_video( app: &tauri::AppHandle, battle_id: &str, video_id: &str, -) -> Result { - History::resolve(app)?.delete_battle_video(battle_id, video_id) +) -> Result { + History::from_resolved_game_path_for_page(History::resolved_game_path(app))? + .delete_battle_video_for_page(battle_id, video_id) } pub fn delete_run_videos( @@ -294,6 +340,12 @@ fn history_paths_for_game_path(game_path: PathBuf) -> HistoryStorage { } } +fn history_action_problem(operation: &str, diagnostic: String) -> SemanticProblem { + SemanticProblem::new(SemanticProblemCode::HistoryActionFailed) + .with_param("operation", operation) + .with_diagnostic(diagnostic) +} + fn require_video_file_exists(path: &Path) -> Result<(), String> { path.try_exists() .map_err(|err| format!("Failed to inspect video file at {}: {err}", path.display()))? @@ -473,7 +525,7 @@ mod tests { #[test] fn history_page_list_uses_semantic_unavailable_and_read_failed_problems() { - let unavailable = History::from_resolved_game_path_for_list(None) + let unavailable = History::from_resolved_game_path_for_page(None) .err() .unwrap(); assert_eq!(unavailable.code, SemanticProblemCode::HistoryUnavailable); @@ -482,7 +534,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let game_path = temp.path().join("The Bazaar"); - let history = History::from_resolved_game_path_for_list(Some(game_path.clone())).unwrap(); + 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(); @@ -495,6 +547,60 @@ mod tests { assert!(read_failed.diagnostic.is_some()); } + #[test] + fn run_detail_page_distinguishes_not_found_read_and_action_problems() { + let unavailable = History::from_resolved_game_path_for_page(None) + .err() + .unwrap(); + assert_eq!(unavailable.code, SemanticProblemCode::HistoryUnavailable); + + 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(); + assert_eq!(history.run_detail_for_page("missing").unwrap(), None); + + std::fs::create_dir_all(history.paths.database_path.parent().unwrap()).unwrap(); + std::fs::write(&history.paths.database_path, b"not sqlite").unwrap(); + + let read_failed = history.run_detail_for_page("run-1").unwrap_err(); + assert_eq!(read_failed.code, SemanticProblemCode::HistoryReadFailed); + assert_eq!( + read_failed.params.get("operation").map(String::as_str), + Some("get_run_detail") + ); + assert!(read_failed.diagnostic.is_some()); + + std::fs::remove_file(&history.paths.database_path).unwrap(); + let revealer = RecordingRevealer::default(); + for (operation, problem) in [ + ( + "reveal_screenshot", + history + .reveal_run_screenshot_for_page("run-1", &revealer) + .unwrap_err(), + ), + ( + "reveal_video", + history + .reveal_battle_video_for_page("battle-1", None, &revealer) + .unwrap_err(), + ), + ( + "delete_video", + history + .delete_battle_video_for_page("battle-1", "video-1") + .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/commandAdapter.ts b/src/api/commandAdapter.ts index 9418899e..e0fb3986 100644 --- a/src/api/commandAdapter.ts +++ b/src/api/commandAdapter.ts @@ -1,16 +1,4 @@ import type { commands as generatedCommands } from '../types/generated/commands'; -type GeneratedCommands = typeof generatedCommands; -type NullablePreviewCommand = 'getHistoryRunDetail' | 'deleteBattleVideo'; - -type AdaptCommand = - GeneratedCommands[K] extends (...args: infer Args) => Promise - ? ( - ...args: Args - ) => Promise - : never; - /** Shared contract implemented by both the generated native client and Preview. */ -export type CommandAdapter = { - [K in keyof GeneratedCommands]: AdaptCommand; -}; +export type CommandAdapter = typeof generatedCommands; diff --git a/src/api/previewCommands.test.ts b/src/api/previewCommands.test.ts index 31bdd055..49f352f0 100644 --- a/src/api/previewCommands.test.ts +++ b/src/api/previewCommands.test.ts @@ -88,9 +88,8 @@ describe('browser-preview command adapter', () => { expect(await commandClient.launchGame()).toEqual({ ok: true }); }); - it('preserves nullable desktop-only preview results', async () => { + it('preserves nullable read-only desktop preview results', async () => { expect(await commandClient.getHistoryRunDetail('r')).toBeNull(); - expect(await commandClient.deleteBattleVideo('b', 'v')).toBeNull(); expect(await commandClient.deleteRunVideos('r', null)).toBe( emptyHistoryRunList ); @@ -115,5 +114,8 @@ describe('browser-preview command adapter', () => { await expect(commandClient.installMod('x', false)).rejects.toBeInstanceOf( Error ); + await expect( + commandClient.deleteBattleVideo('b', 'v') + ).rejects.toBeInstanceOf(Error); }); }); diff --git a/src/api/previewCommands.ts b/src/api/previewCommands.ts index 2b3e1159..5df44024 100644 --- a/src/api/previewCommands.ts +++ b/src/api/previewCommands.ts @@ -37,7 +37,7 @@ export function createPreviewCommands(native: CommandAdapter): CommandAdapter { getHistoryRunDetail: async () => null, revealRunScreenshot: async () => null, revealBattleVideo: async () => null, - deleteBattleVideo: async () => null, + deleteBattleVideo: (...args) => native.deleteBattleVideo(...args), deleteRunVideos: async () => emptyHistoryRunList, previewStorageCleanup: async (scope) => scope === 'screenshots' diff --git a/src/api/problems.ts b/src/api/problems.ts index f24423d8..28b1cc59 100644 --- a/src/api/problems.ts +++ b/src/api/problems.ts @@ -2,7 +2,8 @@ import type { SemanticProblem } from '../types/backend'; const semanticProblemCodes: Record = { history_unavailable: true, - history_read_failed: true + history_read_failed: true, + history_action_failed: true }; export class SemanticProblemError extends Error { diff --git a/src/components/ui/ConfirmDialog.test.tsx b/src/components/ui/ConfirmDialog.test.tsx index 06016109..95d9999a 100644 --- a/src/components/ui/ConfirmDialog.test.tsx +++ b/src/components/ui/ConfirmDialog.test.tsx @@ -94,4 +94,9 @@ describe('ConfirmDialog', () => { it('confirmDisabled disables confirm even when idle (Cleanup nothing-to-clean)', () => { 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); + }); }); diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx index 5078cc39..4ce0f720 100644 --- a/src/components/ui/ConfirmDialog.tsx +++ b/src/components/ui/ConfirmDialog.tsx @@ -40,9 +40,11 @@ export interface ConfirmDialogProps { * Absent => the four danger modals: busy prepends Loader2 animate-spin to * confirmLabel. Presence is the switch. */ busyLabel?: string; - /** Disables confirm + triggers busy affordance. Escape/backdrop/X/cancel - * stay active while busy. */ + /** 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; /** Extra gate (for example, Cleanup having nothing to clean). */ confirmDisabled?: boolean; onConfirm: () => void | Promise; @@ -91,6 +93,7 @@ export function ConfirmDialog({ confirmLabel, busyLabel, busy, + dismissDisabled = false, confirmDisabled, onConfirm, onClose @@ -100,7 +103,10 @@ export function ConfirmDialog({ const Icon = s.Icon; return ( - + undefined : onClose} + labelledBy={titleId} + >
@@ -112,7 +118,8 @@ export function ConfirmDialog({ diff --git a/src/features/history/StorageCleanupCard.tsx b/src/features/history/StorageCleanupCard.tsx index c2c7d69f..8a301347 100644 --- a/src/features/history/StorageCleanupCard.tsx +++ b/src/features/history/StorageCleanupCard.tsx @@ -41,7 +41,7 @@ export function StorageCleanupCard({ }: { onCompleted: () => Promise | void; }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const cleanup = useStorageCleanup(onCompleted); const pendingBody = (pending: PendingCleanup): string => { @@ -51,14 +51,14 @@ export function StorageCleanupCard({ if (pending.scope === 'screenshots') { return t('storageCleanupScreenshotsConfirmBody', { count: pending.preview.screenshots + pending.preview.orphan_files, - size: formatBytes(pending.preview.estimated_bytes) + size: formatBytes(pending.preview.estimated_bytes, locale) }); } return t('storageCleanupRunDataConfirmBody', { runs: pending.preview.runs, battles: pending.preview.battles, videos: pending.preview.videos, - size: formatBytes(pending.preview.estimated_bytes) + size: formatBytes(pending.preview.estimated_bytes, locale) }); }; @@ -66,13 +66,13 @@ export function StorageCleanupCard({ if (outcome.scope === 'screenshots') { return t('storageCleanupScreenshotsDone', { files: outcome.result.deleted_files, - size: formatBytes(outcome.result.freed_bytes) + size: formatBytes(outcome.result.freed_bytes, locale) }); } return t('storageCleanupRunDataDone', { runs: outcome.result.deleted_runs, files: outcome.result.deleted_files, - size: formatBytes(outcome.result.freed_bytes) + size: formatBytes(outcome.result.freed_bytes, locale) }); }; diff --git a/src/features/history/format.test.ts b/src/features/history/format.test.ts index 66eac383..56524e1a 100644 --- a/src/features/history/format.test.ts +++ b/src/features/history/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatDateTime } from './format'; +import { formatBytes, formatDateTime, formatDuration } from './format'; describe('History date formatting', () => { it('follows the selected locale', () => { @@ -8,4 +8,13 @@ describe('History date formatting', () => { expect(formatDateTime(value, 'zh')).not.toBe(formatDateTime(value, 'en')); expect(formatDateTime(value, 'en')).toMatch(/AM|PM/); }); + + it('formats video durations and sizes through the selected locale helpers', () => { + expect(formatDuration(90_000, 'zh')).toContain('1'); + expect(formatDuration(90_000, 'en')).toMatch(/min/i); + expect(formatBytes(1_572_864, 'zh')).toContain('1.5'); + expect(formatBytes(1_572_864, 'en')).toContain('1.5'); + expect(formatDuration(null, 'en')).toBe('-'); + expect(formatBytes(null, 'en')).toBe('-'); + }); }); diff --git a/src/features/history/format.ts b/src/features/history/format.ts index eda42ba3..29120472 100644 --- a/src/features/history/format.ts +++ b/src/features/history/format.ts @@ -16,6 +16,41 @@ const dateTimeFormatters: Record = { }) }; +const numberFormatters: Record = { + zh: new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }), + en: new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }) +}; + +const durationFormatters: Record< + Locale, + { minutes: Intl.NumberFormat; seconds: Intl.NumberFormat } +> = { + zh: { + minutes: new Intl.NumberFormat('zh-CN', { + style: 'unit', + unit: 'minute', + unitDisplay: 'short' + }), + seconds: new Intl.NumberFormat('zh-CN', { + style: 'unit', + unit: 'second', + unitDisplay: 'short' + }) + }, + en: { + minutes: new Intl.NumberFormat('en-US', { + style: 'unit', + unit: 'minute', + unitDisplay: 'short' + }), + seconds: new Intl.NumberFormat('en-US', { + style: 'unit', + unit: 'second', + unitDisplay: 'short' + }) + } +}; + export function formatDateTime( value: string | null | undefined, locale: Locale @@ -75,16 +110,41 @@ export function formatRunStatusKey(status: string): MessageKey { } } -export function formatBytes(bytes: number): string { +export function formatDuration( + durationMs: number | null | undefined, + locale: Locale +): string { + if (durationMs === null || durationMs === undefined || durationMs < 0) { + return '-'; + } + const totalSeconds = Math.round(durationMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes === 0) { + return durationFormatters[locale].seconds.format(seconds); + } + if (seconds === 0) { + return durationFormatters[locale].minutes.format(minutes); + } + return `${durationFormatters[locale].minutes.format(minutes)} ${durationFormatters[locale].seconds.format(seconds)}`; +} + +export function formatBytes( + bytes: number | null | undefined, + locale: Locale +): string { + if (bytes === null || bytes === undefined) { + return '-'; + } if (bytes <= 0) { return '0 MB'; } const mb = bytes / (1024 * 1024); if (mb >= 1024) { - return `${(mb / 1024).toFixed(1)} GB`; + return `${numberFormatters[locale].format(mb / 1024)} GB`; } if (mb >= 1) { - return `${mb.toFixed(1)} MB`; + return `${numberFormatters[locale].format(mb)} MB`; } return `${Math.max(1, Math.round(bytes / 1024))} KB`; } diff --git a/src/features/history/historyProblems.ts b/src/features/history/historyProblems.ts index f3b4c0e0..641de83c 100644 --- a/src/features/history/historyProblems.ts +++ b/src/features/history/historyProblems.ts @@ -1,15 +1,38 @@ -import type { SemanticProblemCode } from '../../types/backend'; import type { Translate } from '../../i18n/LocaleProvider'; import type { MessageKey } from '../../i18n/messages'; -import type { UiProblem } from '../shared/problems'; +import { + createUiProblem, + problemFromError, + type UiProblem +} from '../shared/problems'; export type HistoryPageProblemCode = - | SemanticProblemCode + | 'history_unavailable' + | 'history_read_failed' | 'history_preview_unavailable' | 'history_unexpected'; export type HistoryPageProblem = UiProblem; +export function historyProblemFromError(error: unknown): HistoryPageProblem { + const problem = problemFromError(error, 'history_unexpected'); + switch (problem.code) { + case 'history_unavailable': + case 'history_read_failed': + case 'history_unexpected': + return { + code: problem.code, + params: problem.params, + diagnostic: problem.diagnostic + }; + default: + return createUiProblem('history_unexpected', { + params: problem.params, + diagnostic: problem.diagnostic + }); + } +} + export function historyProblemMessageKey( problem: HistoryPageProblem ): MessageKey { diff --git a/src/features/history/runDetailPageState.test.ts b/src/features/history/runDetailPageState.test.ts new file mode 100644 index 00000000..aa7cc46d --- /dev/null +++ b/src/features/history/runDetailPageState.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; +import type { HistoryRunDetail } from '../../types/backend'; +import { createUiProblem } from '../shared/problems'; +import { + actionAvailability, + actionProblemFor, + beginRunDetailAction, + completeRunDetailAction, + initialRunDetailActionState, + initialRunDetailPageState, + reduceRunDetailPageState, + failRunDetailAction, + type RunDetailPageState +} from './runDetailPageState'; + +const detail: HistoryRunDetail = { + run: { + run_id: 'run-1', + hero: 'Vanessa', + game_mode: 'Ranked', + started_at_utc: '2026-01-02T15:04:00Z', + ended_at_utc: '2026-01-02T15:34:00Z', + last_seen_at_utc: '2026-01-02T15:34:00Z', + status: 'completed', + result: 'win', + victories: 10, + losses: 2, + final_day: 12, + final_hour: 1, + final_player_rank: 'Gold', + final_player_rating: 1234, + screenshot_id: 'shot-1', + strip_url: null, + video_count: 1, + player_name: 'Player' + }, + battles: [] +}; + +const readProblem = createUiProblem('history_read_failed', { + params: { operation: 'get_run_detail' }, + diagnostic: 'database is locked' +}); +const actionProblem = createUiProblem('history_action_failed', { + params: { operation: 'reveal_video' }, + diagnostic: 'video is missing' +}); + +function transition( + state: RunDetailPageState, + event: Parameters[1] +) { + return reduceRunDetailPageState(state, event); +} + +describe('Run Detail page state', () => { + it('keeps initial loading, not found, blocking failure, and ready content distinct', () => { + const loading = transition(initialRunDetailPageState, { + type: 'request-started', + requestId: 1 + }); + expect(loading.phase).toBe('initial-loading'); + + expect( + transition(loading, { + type: 'request-succeeded', + requestId: 1, + data: null + }).phase + ).toBe('not-found'); + + expect( + transition(loading, { + type: 'request-failed', + requestId: 1, + problem: readProblem + }).phase + ).toBe('blocking-failure'); + + expect( + transition(loading, { + type: 'request-succeeded', + requestId: 1, + data: detail + }).phase + ).toBe('ready'); + }); + + it('preserves usable detail through refresh failure and recovers on retry', () => { + const loaded = transition( + { phase: 'initial-loading', requestId: 1 }, + { type: 'request-succeeded', requestId: 1, data: detail } + ); + const refreshing = transition(loaded, { + type: 'request-started', + requestId: 2 + }); + const failed = transition(refreshing, { + type: 'request-failed', + requestId: 2, + problem: readProblem + }); + + expect(failed).toMatchObject({ + phase: 'ready', + data: detail, + refresh: { phase: 'failed', problem: readProblem } + }); + + const retrying = transition(failed, { + type: 'request-started', + requestId: 3 + }); + expect(retrying).toMatchObject({ + phase: 'ready', + data: detail, + refresh: { phase: 'refreshing' } + }); + expect( + transition(retrying, { + type: 'request-succeeded', + requestId: 3, + data: detail + }) + ).toMatchObject({ phase: 'ready', refresh: { phase: 'idle' } }); + }); + + it('ignores a stale completion once a newer request owns the page', () => { + expect( + transition( + { phase: 'initial-loading', requestId: 2 }, + { type: 'request-succeeded', requestId: 1, data: detail } + ) + ).toEqual({ phase: 'initial-loading', requestId: 2 }); + }); +}); + +describe('Run Detail action state', () => { + it('visibly gates every action while the shared single-flight slot is occupied', () => { + const running = beginRunDetailAction( + initialRunDetailActionState, + 'screenshot' + ); + + expect(actionAvailability(running, 'screenshot')).toEqual({ + disabled: true, + running: true + }); + expect(actionAvailability(running, 'video:battle-1')).toEqual({ + disabled: true, + running: false + }); + expect(actionAvailability(running, 'delete:battle-1')).toEqual({ + disabled: true, + running: false + }); + expect(beginRunDetailAction(running, 'video:battle-1')).toBe(running); + expect( + actionAvailability(initialRunDetailActionState, 'video:battle-1', true) + ).toEqual({ disabled: true, running: false }); + }); + + it('attaches failures to their target and clears them when that target retries', () => { + const running = beginRunDetailAction( + initialRunDetailActionState, + 'video:battle-1' + ); + const failed = failRunDetailAction( + running, + 'video:battle-1', + actionProblem + ); + + expect(actionProblemFor(failed, 'battle:battle-1')).toEqual({ + action: 'video:battle-1', + problem: actionProblem + }); + expect(actionProblemFor(failed, 'battle:battle-2')).toBeNull(); + expect(actionProblemFor(failed, 'screenshot')).toBeNull(); + + const retrying = beginRunDetailAction(failed, 'video:battle-1'); + expect(actionProblemFor(retrying, 'battle:battle-1')).toBeNull(); + expect( + completeRunDetailAction(retrying, 'video:battle-1').current + ).toBeNull(); + }); +}); diff --git a/src/features/history/runDetailPageState.ts b/src/features/history/runDetailPageState.ts new file mode 100644 index 00000000..068b6ed9 --- /dev/null +++ b/src/features/history/runDetailPageState.ts @@ -0,0 +1,167 @@ +import type { HistoryRunDetail } from '../../types/backend'; +import type { PageRefreshState } from '../shared/pageState'; +import type { RunDetailProblem } from './runDetailProblems'; + +export type RunDetailPageState = + | { phase: 'initial-loading'; requestId: number } + | { phase: 'not-found'; requestId: number } + | { + phase: 'blocking-failure'; + requestId: number; + problem: RunDetailProblem; + } + | { + phase: 'ready'; + requestId: number; + data: HistoryRunDetail; + refresh: PageRefreshState; + }; + +export type RunDetailPageEvent = + | { type: 'resource-changed'; requestId: number } + | { type: 'request-started'; requestId: number } + | { + type: 'request-succeeded'; + requestId: number; + data: HistoryRunDetail | null; + } + | { + type: 'request-failed'; + requestId: number; + problem: RunDetailProblem; + } + | { type: 'data-replaced'; data: HistoryRunDetail }; + +export const initialRunDetailPageState: RunDetailPageState = { + phase: 'initial-loading', + requestId: 0 +}; + +export function reduceRunDetailPageState( + state: RunDetailPageState, + event: RunDetailPageEvent +): RunDetailPageState { + switch (event.type) { + case 'resource-changed': + return { phase: 'initial-loading', requestId: event.requestId }; + case 'request-started': + if (state.phase === 'ready') { + return { + ...state, + requestId: event.requestId, + refresh: { phase: 'refreshing' } + }; + } + return { phase: 'initial-loading', requestId: event.requestId }; + case 'request-succeeded': + if (state.requestId !== event.requestId) return state; + if (!event.data) { + return { phase: 'not-found', requestId: event.requestId }; + } + return { + phase: 'ready', + requestId: event.requestId, + data: event.data, + refresh: { phase: 'idle' } + }; + case 'request-failed': + if (state.requestId !== event.requestId) return state; + if (state.phase === 'ready') { + return { + ...state, + refresh: { phase: 'failed', problem: event.problem } + }; + } + return { + phase: 'blocking-failure', + requestId: event.requestId, + problem: event.problem + }; + case 'data-replaced': + return replaceRunDetailData(state, event.data); + } +} + +export function replaceRunDetailData( + state: RunDetailPageState, + data: HistoryRunDetail +): RunDetailPageState { + if (state.phase !== 'ready') return state; + return { ...state, data }; +} + +export type RunDetailActionName = + | 'screenshot' + | `video:${string}` + | `delete:${string}`; + +export type RunDetailActionTarget = 'screenshot' | `battle:${string}`; + +export type RunDetailActionFailure = { + action: RunDetailActionName; + problem: RunDetailProblem; +}; + +export type RunDetailActionState = { + current: RunDetailActionName | null; + problems: Partial>; +}; + +export const initialRunDetailActionState: RunDetailActionState = { + current: null, + problems: {} +}; + +export function beginRunDetailAction( + state: RunDetailActionState, + action: RunDetailActionName +): RunDetailActionState { + if (state.current !== null) return state; + const problems = { ...state.problems }; + delete problems[actionTarget(action)]; + return { current: action, problems }; +} + +export function completeRunDetailAction( + state: RunDetailActionState, + action: RunDetailActionName +): RunDetailActionState { + if (state.current !== action) return state; + return { ...state, current: null }; +} + +export function failRunDetailAction( + state: RunDetailActionState, + action: RunDetailActionName, + problem: RunDetailProblem +): RunDetailActionState { + if (state.current !== action) return state; + const target = actionTarget(action); + return { + current: null, + problems: { ...state.problems, [target]: { action, problem } } + }; +} + +export function actionAvailability( + state: RunDetailActionState, + action: RunDetailActionName, + pageRefreshing = false +) { + return { + disabled: pageRefreshing || state.current !== null, + running: state.current === action + }; +} + +export function actionProblemFor( + state: RunDetailActionState, + target: RunDetailActionTarget +): RunDetailActionFailure | null { + return state.problems[target] ?? null; +} + +function actionTarget(action: RunDetailActionName): RunDetailActionTarget { + if (action === 'screenshot') return 'screenshot'; + return `battle:${action.slice(action.indexOf(':') + 1)}`; +} diff --git a/src/features/history/runDetailProblems.test.ts b/src/features/history/runDetailProblems.test.ts new file mode 100644 index 00000000..398024ac --- /dev/null +++ b/src/features/history/runDetailProblems.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { formatMessage } from '../../i18n/messages'; +import { createUiProblem } from '../shared/problems'; +import { presentRunDetailProblem } from './runDetailProblems'; + +const problems = [ + createUiProblem('history_unavailable'), + createUiProblem('history_read_failed', { + params: { operation: 'get_run_detail' } + }), + createUiProblem('history_action_failed', { + params: { operation: 'reveal_screenshot' } + }), + createUiProblem('history_action_failed', { + params: { operation: 'reveal_video' } + }), + createUiProblem('history_action_failed', { + params: { operation: 'delete_video' } + }), + createUiProblem('run_detail_unexpected') +] as const; + +describe('Run Detail problem presentation', () => { + it.each(problems)( + 'has Chinese and English copy for $code $params', + (problem) => { + const zh = presentRunDetailProblem(problem, (key, params) => + formatMessage('zh', key, params) + ); + const en = presentRunDetailProblem(problem, (key, params) => + formatMessage('en', key, params) + ); + + expect(zh).not.toBe(problem.code); + expect(en).not.toBe(problem.code); + expect(en).not.toBe(zh); + } + ); +}); diff --git a/src/features/history/runDetailProblems.ts b/src/features/history/runDetailProblems.ts new file mode 100644 index 00000000..95974936 --- /dev/null +++ b/src/features/history/runDetailProblems.ts @@ -0,0 +1,62 @@ +import type { Translate } from '../../i18n/LocaleProvider'; +import type { MessageKey } from '../../i18n/messages'; +import { + createUiProblem, + problemFromError, + type UiProblem +} from '../shared/problems'; + +export type RunDetailProblemCode = + | 'history_unavailable' + | 'history_read_failed' + | 'history_action_failed' + | 'run_detail_unexpected'; + +export type RunDetailProblem = UiProblem; + +export function runDetailProblemFromError(error: unknown): RunDetailProblem { + const problem = problemFromError(error, 'run_detail_unexpected'); + switch (problem.code) { + case 'history_unavailable': + case 'history_read_failed': + case 'history_action_failed': + case 'run_detail_unexpected': + return problem; + default: + return createUiProblem('run_detail_unexpected', { + params: problem.params, + diagnostic: problem.diagnostic + }); + } +} + +export function runDetailProblemMessageKey( + problem: RunDetailProblem +): MessageKey { + switch (problem.code) { + case 'history_unavailable': + return 'runDetailProblemUnavailable'; + case 'history_read_failed': + return 'runDetailProblemReadFailed'; + case 'history_action_failed': + switch (problem.params.operation) { + case 'reveal_screenshot': + return 'runDetailProblemRevealScreenshotFailed'; + case 'reveal_video': + return 'runDetailProblemRevealVideoFailed'; + case 'delete_video': + return 'runDetailProblemDeleteVideoFailed'; + default: + return 'runDetailProblemUnexpected'; + } + case 'run_detail_unexpected': + return 'runDetailProblemUnexpected'; + } +} + +export function presentRunDetailProblem( + problem: RunDetailProblem, + t: Translate +): string { + return t(runDetailProblemMessageKey(problem), problem.params); +} diff --git a/src/features/history/useHistoryPage.ts b/src/features/history/useHistoryPage.ts index a236c87d..ce983eca 100644 --- a/src/features/history/useHistoryPage.ts +++ b/src/features/history/useHistoryPage.ts @@ -8,10 +8,10 @@ import { } from 'react'; import type { HistoryRunRow } from '../../types/backend'; import { getStreamStatus } from '../shared/streamSessionApi'; -import { problemFromError } from '../shared/problems'; import { isReadyPageState } from '../shared/pageState'; import { optionalStripPreviewUrl } from './stripPreview'; import { listHistoryRuns } from './historyApi'; +import { historyProblemFromError } from './historyProblems'; import { initialHistoryPageState, reduceHistoryPageState @@ -44,7 +44,7 @@ export function useHistoryPage() { dispatch({ type: 'request-failed', requestId, - problem: problemFromError(caught, 'history_unexpected') + problem: historyProblemFromError(caught) }); } }, []); diff --git a/src/features/history/useRunDetailPage.ts b/src/features/history/useRunDetailPage.ts index f325fd96..afe10e3b 100644 --- a/src/features/history/useRunDetailPage.ts +++ b/src/features/history/useRunDetailPage.ts @@ -1,70 +1,161 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; -import type { HistoryRunDetail } from '../../types/backend'; -import { toErrorMessage } from '../shared/errors'; -import { useAsyncAction } from '../shared/useAsyncAction'; import { deleteBattleVideo, loadHistoryRunDetail, revealBattleVideo, revealRunScreenshot } from './historyApi'; +import { + actionAvailability, + actionProblemFor, + beginRunDetailAction, + completeRunDetailAction, + failRunDetailAction, + initialRunDetailActionState, + initialRunDetailPageState, + reduceRunDetailPageState, + type RunDetailActionName, + type RunDetailActionState, + type RunDetailActionTarget +} from './runDetailPageState'; +import { runDetailProblemFromError } from './runDetailProblems'; export function useRunDetailPage() { const { runId } = useParams<{ runId: string }>(); - const [detail, setDetail] = useState(null); - const [loading, setLoading] = useState(true); - const { action, error, setError, run } = useAsyncAction(); + const [state, dispatch] = useReducer( + reduceRunDetailPageState, + initialRunDetailPageState + ); + const [actionState, setActionState] = useState(initialRunDetailActionState); + const actionStateRef = useRef( + initialRunDetailActionState + ); + const requestIdRef = useRef(0); + const requestInFlightRef = useRef(false); + + const commitActionState = useCallback((next: RunDetailActionState) => { + actionStateRef.current = next; + setActionState(next); + }, []); + + const load = useCallback( + async (resourceChanged: boolean) => { + if (actionStateRef.current.current !== null) return false; + const requestId = ++requestIdRef.current; + requestInFlightRef.current = true; + dispatch({ + type: resourceChanged ? 'resource-changed' : 'request-started', + requestId + }); + try { + const data = runId ? await loadHistoryRunDetail(runId) : null; + dispatch({ type: 'request-succeeded', requestId, data }); + if (requestIdRef.current === requestId) { + commitActionState(initialRunDetailActionState); + } + return true; + } catch (caught) { + dispatch({ + type: 'request-failed', + requestId, + problem: runDetailProblemFromError(caught) + }); + return false; + } finally { + if (requestIdRef.current === requestId) { + requestInFlightRef.current = false; + } + } + }, + [commitActionState, runId] + ); - const refresh = useCallback(async () => { - if (!runId) { - setDetail(null); - setLoading(false); - return; - } - setLoading(true); - setError(null); - try { - const nextDetail = await loadHistoryRunDetail(runId); - setDetail(nextDetail); - } catch (caught) { - setError(toErrorMessage(caught)); - } finally { - setLoading(false); - } - }, [runId, setError]); + const refresh = useCallback(() => load(false), [load]); useEffect(() => { - void refresh(); - }, [refresh]); + void load(true); + }, [load]); - const revealScreenshot = useCallback(() => { - if (!detail) return; - void run('screenshot', () => revealRunScreenshot(detail.run.run_id)); - }, [detail, run]); + const runAction = useCallback( + async (name: RunDetailActionName, task: () => Promise) => { + if (requestInFlightRef.current) return false; + const previous = actionStateRef.current; + const started = beginRunDetailAction(previous, name); + if (started === previous) return false; + commitActionState(started); + + try { + await task(); + commitActionState( + completeRunDetailAction(actionStateRef.current, name) + ); + return true; + } catch (caught) { + commitActionState( + failRunDetailAction( + actionStateRef.current, + name, + runDetailProblemFromError(caught) + ) + ); + return false; + } + }, + [commitActionState] + ); + + const revealScreenshot = useCallback(async () => { + if (state.phase !== 'ready') return false; + return runAction('screenshot', () => + revealRunScreenshot(state.data.run.run_id) + ); + }, [runAction, state]); const revealVideo = useCallback( - (battleId: string, videoId?: string) => { - void run(`video:${battleId}`, () => revealBattleVideo(battleId, videoId)); + async (battleId: string, videoId?: string) => { + if (state.phase !== 'ready' || !videoId) return false; + return runAction(`video:${battleId}`, () => + revealBattleVideo(battleId, videoId) + ); }, - [run] + [runAction, state.phase] ); const deleteVideo = useCallback( (battleId: string, videoId: string) => - run(`delete:${battleId}`, async () => { - setDetail(await deleteBattleVideo(battleId, videoId)); + runAction(`delete:${battleId}`, async () => { + const data = await deleteBattleVideo(battleId, videoId); + dispatch({ type: 'data-replaced', data }); }), - [run] + [runAction] + ); + + const refreshing = + state.phase === 'ready' && state.refresh.phase === 'refreshing'; + const availability = useCallback( + (action: RunDetailActionName) => + actionAvailability(actionState, action, refreshing), + [actionState, refreshing] + ); + const problemFor = useCallback( + (target: RunDetailActionTarget) => actionProblemFor(actionState, target), + [actionState] ); return { runId, - detail, - loading, - action, - error, + state, + detail: state.phase === 'ready' ? state.data : null, + action: actionState.current, + busy: + state.phase === 'initial-loading' || + refreshing || + actionState.current !== null, + refreshing, refresh, + availability, + problemFor, revealScreenshot, revealVideo, deleteVideo diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 13ddd4b7..0a4004c9 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -188,6 +188,16 @@ const zh = { runDetailBack: '返回战绩列表', runDetailLoading: '读取详情中', runDetailNotFound: '没有找到这局战绩', + runDetailRefreshing: '正在刷新详情', + runDetailProblemUnavailable: + '未找到可用的本地战绩数据库。请先在安装页选择正确的游戏目录。', + runDetailProblemReadFailed: + '读取这局战绩失败。请关闭可能占用数据库的程序后重试。', + runDetailProblemRevealScreenshotFailed: + '无法打开这局战绩的截图位置,请重试。', + runDetailProblemRevealVideoFailed: '无法打开这场战斗的视频位置,请重试。', + runDetailProblemDeleteVideoFailed: '删除这场战斗的视频失败,请重试。', + runDetailProblemUnexpected: '处理这局战绩时发生意外错误,请重试。', runDetailPlayer: '玩家', runStatusCompleted: '已完成', runStatusAbandoned: '已放弃', @@ -453,6 +463,19 @@ const en: Record = { runDetailBack: 'Back to History', runDetailLoading: 'Loading details', runDetailNotFound: 'This run was not found', + runDetailRefreshing: 'Refreshing details', + runDetailProblemUnavailable: + 'No local History database is available. Select the correct game folder on the Install page.', + runDetailProblemReadFailed: + 'This run could not be read. Close apps that may be using the database, then retry.', + runDetailProblemRevealScreenshotFailed: + 'The screenshot location could not be opened. Please retry.', + runDetailProblemRevealVideoFailed: + 'The video location could not be opened. Please retry.', + runDetailProblemDeleteVideoFailed: + 'The battle video could not be deleted. Please retry.', + runDetailProblemUnexpected: + 'Something unexpected happened while handling this run. Please retry.', runDetailPlayer: 'Player', runStatusCompleted: 'Completed', runStatusAbandoned: 'Abandoned', diff --git a/src/pages/RunDetail.tsx b/src/pages/RunDetail.tsx index fda5b03e..5c5e7c8d 100644 --- a/src/pages/RunDetail.tsx +++ b/src/pages/RunDetail.tsx @@ -3,29 +3,38 @@ import { FileQuestion, Image as ImageIcon, Loader2, + RefreshCw, Trash2, Video } from 'lucide-react'; import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { ConfirmDialog } from '../components/ui/ConfirmDialog'; -import { ErrorBanner } from '../components/ui/ErrorBanner'; +import { LoadingPanel } from '../components/ui/LoadingPanel'; +import { ProblemBanner } from '../components/ui/ProblemBanner'; import type { HistoryBattleRow } from '../types/backend'; import { useRunDetailPage } from '../features/history/useRunDetailPage'; import { formatBattleResult, + formatBytes, formatDateTime, + formatDuration, formatRunResultLabel, formatRunStatusKey, toneColorClass } from '../features/history/format'; +import { + presentRunDetailProblem, + type RunDetailProblem +} from '../features/history/runDetailProblems'; +import { formatProblemDiagnostic } from '../features/shared/problems'; import { useI18n } from '../i18n/LocaleProvider'; // Shared 7-track grid for the battle table header + rows so columns align and -// the action column is a fixed 5rem (no reflow when the hover-only delete -// button appears). Day · Result · OppHero · OppPlayer · Rank · Rating · Video. +// the action/metadata column does not reflow when the hover-only delete button +// appears. Day · Result · OppHero · OppPlayer · Rank · Rating · Video. const BATTLE_GRID = - 'grid grid-cols-[3.5rem_4.5rem_minmax(0,1fr)_minmax(0,1fr)_5rem_5rem_5rem] gap-4'; + 'grid grid-cols-[3.5rem_4.5rem_minmax(0,1fr)_minmax(0,1fr)_5rem_5rem_9rem] gap-4'; export default function RunDetail() { const navigate = useNavigate(); @@ -37,6 +46,11 @@ export default function RunDetail() { battleId: string; videoId: string; } | null>(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; @@ -51,27 +65,69 @@ export default function RunDetail() { return (
- +
+ + +
- {page.loading ? ( -
- - {t('runDetailLoading')} -
- ) : !detail ? ( -
- {page.error ?? t('runDetailNotFound')} + {page.state.phase === 'initial-loading' ? ( + + ) : page.state.phase === 'not-found' ? ( +
+ {t('runDetailNotFound')} +
- ) : ( + ) : page.state.phase === 'blocking-failure' ? ( + void page.refresh()} + /> + ) : detail ? ( <> - {page.error && } + {page.state.refresh.phase === 'failed' && ( + void page.refresh()} + /> + )} + + {page.state.refresh.phase === 'refreshing' && ( +
+ + {t('runDetailRefreshing')} +
+ )}
@@ -104,15 +160,27 @@ export default function RunDetail() {
+ {screenshotFailure && ( + void page.revealScreenshot()} + /> + )} +
- )} + ) : null} {pendingDelete && ( setPendingDelete(null)} >

{t('deleteVideoConfirmBody')}

+ {pendingDeleteFailure?.action.startsWith('delete:') && ( + + )}
)}
@@ -228,92 +303,155 @@ function BattleRow({ page: ReturnType; onRequestDelete: (battleId: string, videoId: string) => void; }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const battleResult = formatBattleResult(battle.result); - const videoAction = page.action === `video:${battle.battle_id}`; - const deleteAction = page.action === `delete:${battle.battle_id}`; + const videoAction = `video:${battle.battle_id}` as const; + const deleteAction = `delete:${battle.battle_id}` as const; + const videoAvailability = page.availability(videoAction); + const deleteAvailability = page.availability(deleteAction); + const failure = page.problemFor(`battle:${battle.battle_id}`); + + const retryFailure = () => { + if (!battle.video || !failure) return; + if (failure.action === videoAction) { + void page.revealVideo(battle.battle_id, battle.video.video_id); + return; + } + onRequestDelete(battle.battle_id, battle.video.video_id); + }; return ( -
- +
+
+ -
- {battle.day === null ? '-' : String(battle.day)} -
-
- {t(battleResult.key)} -
-
- {battle.opponent_hero ?? '-'} -
-
- {battle.opponent_name ?? '-'} -
-
- {battle.opponent_rank ?? '-'} -
-
- {battle.opponent_rating === null ? '-' : battle.opponent_rating} -
+
+ {battle.day === null ? '-' : String(battle.day)} +
+
+ {t(battleResult.key)} +
+
+ {battle.opponent_hero ?? '-'} +
+
+ {battle.opponent_name ?? '-'} +
+
+ {battle.opponent_rank ?? '-'} +
+
+ {battle.opponent_rating === null ? '-' : battle.opponent_rating} +
-
- {battle.video ? ( - <> - - + +
+ + {formatDuration(battle.video.duration_ms, locale)} ·{' '} + {formatBytes(battle.video.file_size_bytes, locale)} + + + ) : ( + - {deleteAction ? ( - - ) : ( - - )} - - - ) : ( - - - - )} + + + )} +
+ + {failure && ( +
+ +
+ )}
); } + +function RunDetailProblemBanner({ + problem, + onRetry +}: { + problem: RunDetailProblem; + onRetry: () => void; +}) { + const { t } = useI18n(); + return ( + + {problem.code === 'history_unavailable' && ( + + {t('historyOpenInstall')} + + )} + + + } + /> + ); +} diff --git a/src/types/generated/commands.ts b/src/types/generated/commands.ts index c1268fed..2480bed4 100644 --- a/src/types/generated/commands.ts +++ b/src/types/generated/commands.ts @@ -22,7 +22,10 @@ export const commands = { applyOverlayCropCode: (code: string) => __TAURI_INVOKE("apply_overlay_crop_code", { code }), resetOverlayCrop: () => __TAURI_INVOKE("reset_overlay_crop"), listHistoryRuns: (limit: number | null) => __TAURI_INVOKE("list_history_runs", { limit }), - getHistoryRunDetail: (runId: string) => __TAURI_INVOKE("get_history_run_detail", { runId }), + getHistoryRunDetail: (runId: string) => __TAURI_INVOKE<{ + run: HistoryRunDetailRow, + battles: HistoryBattleRow[], +} | null>("get_history_run_detail", { runId }), revealRunScreenshot: (runId: string) => __TAURI_INVOKE("reveal_run_screenshot", { runId }), revealBattleVideo: (battleId: string, videoId: string | null) => __TAURI_INVOKE("reveal_battle_video", { battleId, videoId }), deleteBattleVideo: (battleId: string, videoId: string) => __TAURI_INVOKE("delete_battle_video", { battleId, videoId }), @@ -253,7 +256,7 @@ export type SemanticProblem = { diagnostic: string | null, }; -export type SemanticProblemCode = "history_unavailable" | "history_read_failed"; +export type SemanticProblemCode = "history_unavailable" | "history_read_failed" | "history_action_failed"; export type StorageCleanupExecution = { scope: "screenshots"; result: ScreenshotCleanupResult } | { scope: "run_data"; result: RunDataCleanupResult }; From 045f7016382497fce2d8a8fd000f4b4c4d037b79 Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 02:04:47 +0800 Subject: [PATCH 2/4] docs: verify Run Detail contracts --- CONTEXT.md | 6 +++--- docs/INDEX.md | 12 +++++++----- docs/truth/architecture.md | 6 +++--- docs/truth/frontend.md | 7 ++++--- docs/truth/history-stream.md | 15 +++++++++------ docs/truth/verification.md | 9 +++++---- 6 files changed, 31 insertions(+), 24 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 95add0b9..55702e9c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,7 +1,7 @@ --- status: truth topic: context -last-verified: b07adb2e67f03480d039352837037c25a75f3472 +last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb --- # BazaarPlusPlus Installer Context @@ -27,8 +27,8 @@ 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, warnings (`src-tauri/src/services/install/types.rs:3-19`). - **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:46-231`); 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-35`). History list loading currently publishes `history_unavailable` and `history_read_failed`; the frontend adapter preserves the structured payload instead of turning it into display copy (`src/api/problems.ts:3-41`, `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-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-35`). History list/detail loading publishes `history_unavailable` and `history_read_failed`, while screenshot/video actions publish `history_action_failed` with an operation parameter; the frontend adapter preserves the structured payload instead of turning it into display copy (`src-tauri/src/services/history.rs:74-188`, `src/api/problems.ts:3-42`, `src/api/nativeCommands.ts:5-15`). - **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 Stream page initialization, polling, intents, error priority, and its single derived snapshot. Browser/Tauri concerns enter through injected ports, and React only attaches lifecycle and subscription (`src/features/stream/streamWorkflow.ts:94-120`, `src/features/stream/useStreamPage.ts:21-61`). - **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`). diff --git a/docs/INDEX.md b/docs/INDEX.md index faa80983..5f5e825e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -12,6 +12,8 @@ Build workflow citation refresh: `2026-07-18` on `45764680a4476063a46a92f4606dd5 History page-state citation refresh: `2026-07-19` on `b07adb2e67f03480d039352837037c25a75f3472` — the context glossary plus architecture, frontend, History/Stream, and verification topics were checked against the semantic-problem and independent History loading implementation after review fixes. +Run Detail page-state citation refresh: `2026-07-19` on `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` — the context glossary plus architecture, frontend, History/Stream, and verification topics were checked against the nullable-detail, semantic-action, preserved-refresh, and single-flight implementation. + ## Current Manifest | Path | Topic | Status | Last verified | @@ -21,14 +23,14 @@ History page-state citation refresh: `2026-07-19` on `b07adb2e67f03480d039352837 | `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 | `b07adb2e67f03480d039352837037c25a75f3472` | -| `docs/truth/architecture.md` | architecture | truth | `b07adb2e67f03480d039352837037c25a75f3472` | -| `docs/truth/frontend.md` | frontend | truth | `b07adb2e67f03480d039352837037c25a75f3472` | +| `CONTEXT.md` | entry map + glossary | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | +| `docs/truth/architecture.md` | architecture | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | +| `docs/truth/frontend.md` | frontend | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | | `docs/truth/install-reset.md` | install-reset | truth | `7500016b1c4adfc7b5d0206c7def0ceabae514d5` | | `docs/truth/launch-modes.md` | launch-modes | truth | `4366cda394fe304066b55564c3c44d1f917a2273` | -| `docs/truth/history-stream.md` | history-stream | truth | `b07adb2e67f03480d039352837037c25a75f3472` | +| `docs/truth/history-stream.md` | history-stream | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | | `docs/truth/updater-release.md` | updater-release | truth | `45764680a4476063a46a92f4606dd520f0ce29ef` | -| `docs/truth/verification.md` | verification | truth | `b07adb2e67f03480d039352837037c25a75f3472` | +| `docs/truth/verification.md` | verification | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | | `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 bb606d02..a5169664 100644 --- a/docs/truth/architecture.md +++ b/docs/truth/architecture.md @@ -1,7 +1,7 @@ --- status: truth topic: architecture -last-verified: b07adb2e67f03480d039352837037c25a75f3472 +last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb --- # Architecture @@ -23,8 +23,8 @@ last-verified: b07adb2e67f03480d039352837037c25a75f3472 - 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, and warnings in `src-tauri/src/services/install/types.rs:3-19`. - 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:16-125`; the Tauri command only constructs the request and invokes that operation in `src-tauri/src/commands/install.rs:40-55`. Reset, uninstall, and Steam-only launch remain in the install service facade. -- 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:46-231`; 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`. -- `list_history_runs` is the first command whose failure type is `SemanticProblem`; the shared Rust DTO fixes code/parameter/diagnostic shape, and the History facade maps unavailable selection and read failures before the command boundary in `src-tauri/src/problem.rs:3-35`, `src-tauri/src/services/history.rs:74-90`, and `src-tauri/src/commands/history.rs:7-14`. +- 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-35`, `src-tauri/src/services/history.rs:74-188`, and `src-tauri/src/commands/history.rs:7-49`. - 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`. - The frontend Stream workflow depends inward on semantic command, scheduler, clipboard, and opener ports in `src/features/stream/streamWorkflow.ts:24-50` and `src/features/stream/streamWorkflow.ts:114-120`; the React hook provides those outer adapters and only subscribes, starts, and disposes the workflow in `src/features/stream/useStreamPage.ts:21-61`. diff --git a/docs/truth/frontend.md b/docs/truth/frontend.md index 7510f43c..193bba49 100644 --- a/docs/truth/frontend.md +++ b/docs/truth/frontend.md @@ -1,7 +1,7 @@ --- status: truth topic: frontend -last-verified: b07adb2e67f03480d039352837037c25a75f3472 +last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb --- # Frontend @@ -18,7 +18,7 @@ last-verified: b07adb2e67f03480d039352837037c25a75f3472 - `.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, and busy affordances in `src/components/ui/ConfirmDialog.tsx:52-170`; feature call sites provide direct body children so their rendered DOM stays unchanged. +- 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`. - 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: b07adb2e67f03480d039352837037c25a75f3472 - 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 lifecycle initialization, polling thresholds and response epochs, action serialization, error priority, transient messages, and the derived page snapshot in `src/features/stream/streamWorkflow.ts:139-293` and `src/features/stream/streamWorkflow.ts:395-539`; `useStreamPage` only supplies browser ports and binds its lifecycle to React in `src/features/stream/useStreamPage.ts:10-61`. - 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`. +- 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 @@ -38,7 +39,7 @@ last-verified: b07adb2e67f03480d039352837037c25a75f3472 - 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 shows a hero/result header, run stats, screenshot reveal, and a battle table with fixed columns and video reveal/delete actions in `src/pages/RunDetail.tsx:52-176` and `src/pages/RunDetail.tsx:222-319`. +- 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`. - Stream renders only the workflow snapshot and invokes its intents; status copy, feedback, and control availability are no longer recomputed in the page in `src/pages/Stream.tsx:26-131` and `src/pages/Stream.tsx:135-240`. diff --git a/docs/truth/history-stream.md b/docs/truth/history-stream.md index 55f54cd4..8ee160d9 100644 --- a/docs/truth/history-stream.md +++ b/docs/truth/history-stream.md @@ -1,15 +1,16 @@ --- status: truth topic: history-stream -last-verified: b07adb2e67f03480d039352837037c25a75f3472 +last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb --- # 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:46-231`; it does not borrow Stream runtime state. -- List resolution emits `history_unavailable`, while SQLite/query failures emit `history_read_failed` with the stable `operation=list_runs` parameter and an optional diagnostic in `src-tauri/src/services/history.rs:74-90` and `src-tauri/src/services/history.rs:233-239`. Their shared serialized contract is defined in `src-tauri/src/problem.rs:3-35` and crosses the Tauri command boundary at `src-tauri/src/commands/history.rs:7-14`. +- 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-35` 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`. - 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`. @@ -23,16 +24,18 @@ last-verified: b07adb2e67f03480d039352837037c25a75f3472 - The History page state union makes initial loading, blocking failure, ready-empty, and ready-content exclusive, while refreshing and refresh failure retain successful data in `src/features/shared/pageState.ts:1-60`, `src/features/history/historyPageState.ts:10-42`, and `src/pages/History.tsx:42-97`. - 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 renders run metadata, screenshot reveal, summary stats, and a battle table with video reveal/delete controls in `src/pages/RunDetail.tsx:52-176` and `src/pages/RunDetail.tsx:222-319`. +- 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`. ## 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:161-217`; 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:512-618`. +- 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`. - 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:161-217` 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). +- 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). - Run-data cleanup plans only non-active runs, skips upload-unsafe completed Ranked dirty runs, replay-dirty battles, and pending screenshot uploads, and protects screenshot/video files still referenced by kept rows in `src-tauri/src/history/cleanup.rs:308-398`. - Run-data execution opens the FK-enabled cleanup connection, validates required cascade foreign keys before removing files, deletes replay videos, replay payloads, and eligible screenshots before deleting rows, then removes video rows, screenshot rows, and `runs` rows without directly deleting `battles`; cleanup file resolution refuses drive-relative escapes in `src-tauri/src/history/cleanup.rs:411-524`. - The FK-enabled cleanup connection turns on `PRAGMA foreign_keys = ON` in `src-tauri/src/history/queries.rs:49-54`, so current-schema `runs` deletes cascade to run-owned child rows while ghost battles remain outside run cleanup. diff --git a/docs/truth/verification.md b/docs/truth/verification.md index a3e7a6b4..4aae75d0 100644 --- a/docs/truth/verification.md +++ b/docs/truth/verification.md @@ -1,7 +1,7 @@ --- status: truth topic: verification -last-verified: b07adb2e67f03480d039352837037c25a75f3472 +last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb --- # Verification @@ -37,10 +37,11 @@ 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:512-618`. +- 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 framework-neutral Stream workflow uses fake ports and a fake scheduler to cover initialization failures, polling threshold/recovery, stale-response and lifecycle epochs, action exclusion, window/crop updates, transient feedback, disposal/restart, and both command adapters in `src/features/stream/streamWorkflow.test.ts:133-450`. -- Semantic-problem serialization and History list classification are covered at the Rust boundary in `src-tauri/src/problem.rs:37-68` and `src-tauri/src/services/history.rs:474-496`; the native adapter preservation path is covered in `src/api/commandClient.dispatch.test.ts:38-54`. -- 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-11`. +- Semantic-problem serialization plus History list/detail/action classification are covered at the Rust boundary in `src-tauri/src/problem.rs:38-68` and `src-tauri/src/services/history.rs:526-602`; the native adapter preservation path is covered in `src/api/commandClient.dispatch.test.ts:38-54`. +- 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`. ## Version And Platform Guards From 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 02:05:32 +0800 Subject: [PATCH 3/4] fix: preserve semantic problem code names --- src-tauri/src/problem.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/problem.rs b/src-tauri/src/problem.rs index 7432839a..33414b5a 100644 --- a/src-tauri/src/problem.rs +++ b/src-tauri/src/problem.rs @@ -9,6 +9,9 @@ pub struct SemanticProblem { #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)] #[serde(rename_all = "snake_case")] +// The domain prefix is intentional: these names are the stable cross-language +// problem codes and must remain unambiguous as other feature domains are added. +#[allow(clippy::enum_variant_names)] pub enum SemanticProblemCode { HistoryUnavailable, HistoryReadFailed, From 0d16cd8b0a53c58decfac96a342ffe77a38803eb Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 02:06:03 +0800 Subject: [PATCH 4/4] docs: refresh Run Detail verification hash --- CONTEXT.md | 4 ++-- docs/INDEX.md | 12 ++++++------ docs/truth/architecture.md | 4 ++-- docs/truth/frontend.md | 2 +- docs/truth/history-stream.md | 4 ++-- docs/truth/verification.md | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 55702e9c..f77d4cc7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,7 +1,7 @@ --- status: truth topic: context -last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb +last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 --- # BazaarPlusPlus Installer Context @@ -28,7 +28,7 @@ Current behavior truth lives under `docs/truth/` (topic-sliced, code-cited, hash - **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-35`). History list/detail loading publishes `history_unavailable` and `history_read_failed`, while screenshot/video actions publish `history_action_failed` with an operation parameter; the frontend adapter preserves the structured payload instead of turning it into display copy (`src-tauri/src/services/history.rs:74-188`, `src/api/problems.ts:3-42`, `src/api/nativeCommands.ts:5-15`). +- **Semantic problem** — a command failure contract made of a stable code, string parameters, and an optional troubleshooting diagnostic (`src-tauri/src/problem.rs:3-38`). History list/detail loading publishes `history_unavailable` and `history_read_failed`, while screenshot/video actions publish `history_action_failed` with an operation parameter; the frontend adapter preserves the structured payload instead of turning it into display copy (`src-tauri/src/services/history.rs:74-188`, `src/api/problems.ts:3-42`, `src/api/nativeCommands.ts:5-15`). - **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 Stream page initialization, polling, intents, error priority, and its single derived snapshot. Browser/Tauri concerns enter through injected ports, and React only attaches lifecycle and subscription (`src/features/stream/streamWorkflow.ts:94-120`, `src/features/stream/useStreamPage.ts:21-61`). - **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`). diff --git a/docs/INDEX.md b/docs/INDEX.md index 5f5e825e..24dc9a80 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -12,7 +12,7 @@ Build workflow citation refresh: `2026-07-18` on `45764680a4476063a46a92f4606dd5 History page-state citation refresh: `2026-07-19` on `b07adb2e67f03480d039352837037c25a75f3472` — the context glossary plus architecture, frontend, History/Stream, and verification topics were checked against the semantic-problem and independent History loading implementation after review fixes. -Run Detail page-state citation refresh: `2026-07-19` on `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` — the context glossary plus architecture, frontend, History/Stream, and verification topics were checked against the nullable-detail, semantic-action, preserved-refresh, and single-flight implementation. +Run Detail page-state citation refresh: `2026-07-19` on `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` — the context glossary plus architecture, frontend, History/Stream, and verification topics were checked against the nullable-detail, semantic-action, preserved-refresh, and single-flight implementation. ## Current Manifest @@ -23,14 +23,14 @@ Run Detail page-state citation refresh: `2026-07-19` on `d7b3dee85f26f15bc46c0f4 | `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 | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | -| `docs/truth/architecture.md` | architecture | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | -| `docs/truth/frontend.md` | frontend | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | +| `CONTEXT.md` | entry map + glossary | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | +| `docs/truth/architecture.md` | architecture | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | +| `docs/truth/frontend.md` | frontend | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | | `docs/truth/install-reset.md` | install-reset | truth | `7500016b1c4adfc7b5d0206c7def0ceabae514d5` | | `docs/truth/launch-modes.md` | launch-modes | truth | `4366cda394fe304066b55564c3c44d1f917a2273` | -| `docs/truth/history-stream.md` | history-stream | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | +| `docs/truth/history-stream.md` | history-stream | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | | `docs/truth/updater-release.md` | updater-release | truth | `45764680a4476063a46a92f4606dd520f0ce29ef` | -| `docs/truth/verification.md` | verification | truth | `d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb` | +| `docs/truth/verification.md` | verification | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | | `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 a5169664..574a7a64 100644 --- a/docs/truth/architecture.md +++ b/docs/truth/architecture.md @@ -1,7 +1,7 @@ --- status: truth topic: architecture -last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb +last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 --- # Architecture @@ -24,7 +24,7 @@ last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb - 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, and warnings in `src-tauri/src/services/install/types.rs:3-19`. - 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:16-125`; the Tauri command only constructs the request and invokes that operation in `src-tauri/src/commands/install.rs:40-55`. Reset, uninstall, and Steam-only launch remain in the install service facade. - 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-35`, `src-tauri/src/services/history.rs:74-188`, and `src-tauri/src/commands/history.rs:7-49`. +- 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`. - 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`. - The frontend Stream workflow depends inward on semantic command, scheduler, clipboard, and opener ports in `src/features/stream/streamWorkflow.ts:24-50` and `src/features/stream/streamWorkflow.ts:114-120`; the React hook provides those outer adapters and only subscribes, starts, and disposes the workflow in `src/features/stream/useStreamPage.ts:21-61`. diff --git a/docs/truth/frontend.md b/docs/truth/frontend.md index 193bba49..aa96ddf6 100644 --- a/docs/truth/frontend.md +++ b/docs/truth/frontend.md @@ -1,7 +1,7 @@ --- status: truth topic: frontend -last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb +last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 --- # Frontend diff --git a/docs/truth/history-stream.md b/docs/truth/history-stream.md index 8ee160d9..92a2e362 100644 --- a/docs/truth/history-stream.md +++ b/docs/truth/history-stream.md @@ -1,7 +1,7 @@ --- status: truth topic: history-stream -last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb +last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 --- # History And Stream @@ -9,7 +9,7 @@ last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb ## 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-35` and crosses the Tauri command boundary at `src-tauri/src/commands/history.rs:7-49`. +- 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`. - `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`. - History reads open the BazaarPlusPlus SQLite database read-only with a two-second busy timeout in `src-tauri/src/history/queries.rs:29-35`. diff --git a/docs/truth/verification.md b/docs/truth/verification.md index 4aae75d0..0e63bc29 100644 --- a/docs/truth/verification.md +++ b/docs/truth/verification.md @@ -1,7 +1,7 @@ --- status: truth topic: verification -last-verified: d7b3dee85f26f15bc46c0f4a64d1e6dc21e4deeb +last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 --- # Verification @@ -39,7 +39,7 @@ Use the smallest command that verifies the changed behavior; use the authoritati - 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 framework-neutral Stream workflow uses fake ports and a fake scheduler to cover initialization failures, polling threshold/recovery, stale-response and lifecycle epochs, action exclusion, window/crop updates, transient feedback, disposal/restart, and both command adapters in `src/features/stream/streamWorkflow.test.ts:133-450`. -- Semantic-problem serialization plus History list/detail/action classification are covered at the Rust boundary in `src-tauri/src/problem.rs:38-68` and `src-tauri/src/services/history.rs:526-602`; the native adapter preservation path is covered in `src/api/commandClient.dispatch.test.ts:38-54`. +- Semantic-problem serialization plus History list/detail/action classification are covered at the Rust boundary in `src-tauri/src/problem.rs:41-71` and `src-tauri/src/services/history.rs:526-602`; the native adapter preservation path is covered in `src/api/commandClient.dispatch.test.ts:38-54`. - 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`.