From e35f0a714e15bdbf39d0be601581fdce40ee0dda Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Sat, 21 Mar 2026 12:06:29 +0800 Subject: [PATCH] improve history performance (#1107) * paginate history * fixes * fix * format * remove unecessary key --- src-tauri/src/commands/history.rs | 8 +- src-tauri/src/lib.rs | 210 ++++++++-------- src-tauri/src/managers/history.rs | 120 +++++++-- src/bindings.ts | 11 +- .../settings/history/HistorySettings.tsx | 231 +++++++++++------- 5 files changed, 355 insertions(+), 225 deletions(-) diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 95f909a..cf6c13c 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -1,4 +1,4 @@ -use crate::managers::history::{HistoryEntry, HistoryManager}; +use crate::managers::history::{HistoryManager, PaginatedHistory}; use std::sync::Arc; use tauri::{AppHandle, State}; @@ -7,9 +7,11 @@ use tauri::{AppHandle, State}; pub async fn get_history_entries( _app: AppHandle, history_manager: State<'_, Arc>, -) -> Result, String> { + cursor: Option, + limit: Option, +) -> Result { history_manager - .get_history_entries() + .get_history_entries(cursor, limit) .await .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b76bfda..f49d165 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,7 +23,7 @@ mod utils; pub use cli::CliArgs; #[cfg(debug_assertions)] use specta_typescript::{BigIntExportBehavior, Typescript}; -use tauri_specta::{collect_commands, Builder}; +use tauri_specta::{collect_commands, collect_events, Builder}; use env_filter::Builder as EnvFilterBuilder; use managers::audio::AudioRecordingManager; @@ -321,106 +321,108 @@ pub fn run(cli_args: CliArgs) { // when the variable is unset let console_filter = build_console_filter(); - let specta_builder = Builder::::new().commands(collect_commands![ - shortcut::change_binding, - shortcut::reset_binding, - shortcut::change_ptt_setting, - shortcut::change_audio_feedback_setting, - shortcut::change_audio_feedback_volume_setting, - shortcut::change_sound_theme_setting, - shortcut::change_start_hidden_setting, - shortcut::change_autostart_setting, - shortcut::change_translate_to_english_setting, - shortcut::change_selected_language_setting, - shortcut::change_overlay_position_setting, - shortcut::change_debug_mode_setting, - shortcut::change_word_correction_threshold_setting, - shortcut::change_extra_recording_buffer_setting, - shortcut::change_paste_method_setting, - shortcut::get_available_typing_tools, - shortcut::change_typing_tool_setting, - shortcut::change_external_script_path_setting, - shortcut::change_clipboard_handling_setting, - shortcut::change_auto_submit_setting, - shortcut::change_auto_submit_key_setting, - shortcut::change_post_process_enabled_setting, - shortcut::change_experimental_enabled_setting, - shortcut::change_post_process_base_url_setting, - shortcut::change_post_process_api_key_setting, - shortcut::change_post_process_model_setting, - shortcut::set_post_process_provider, - shortcut::fetch_post_process_models, - shortcut::add_post_process_prompt, - shortcut::update_post_process_prompt, - shortcut::delete_post_process_prompt, - shortcut::set_post_process_selected_prompt, - shortcut::update_custom_words, - shortcut::suspend_binding, - shortcut::resume_binding, - shortcut::change_mute_while_recording_setting, - shortcut::change_append_trailing_space_setting, - shortcut::change_lazy_stream_close_setting, - shortcut::change_app_language_setting, - shortcut::change_update_checks_setting, - shortcut::change_keyboard_implementation_setting, - shortcut::get_keyboard_implementation, - shortcut::change_show_tray_icon_setting, - shortcut::change_whisper_accelerator_setting, - shortcut::change_ort_accelerator_setting, - shortcut::get_available_accelerators, - shortcut::handy_keys::start_handy_keys_recording, - shortcut::handy_keys::stop_handy_keys_recording, - trigger_update_check, - show_main_window_command, - commands::cancel_operation, - commands::get_app_dir_path, - commands::get_app_settings, - commands::get_default_settings, - commands::get_log_dir_path, - commands::set_log_level, - commands::open_recordings_folder, - commands::open_log_dir, - commands::open_app_data_dir, - commands::check_apple_intelligence_available, - commands::initialize_enigo, - commands::initialize_shortcuts, - commands::models::get_available_models, - commands::models::get_model_info, - commands::models::download_model, - commands::models::delete_model, - commands::models::cancel_download, - commands::models::set_active_model, - commands::models::get_current_model, - commands::models::get_transcription_model_status, - commands::models::is_model_loading, - commands::models::has_any_models_available, - commands::models::has_any_models_or_downloads, - commands::audio::update_microphone_mode, - commands::audio::get_microphone_mode, - commands::audio::get_windows_microphone_permission_status, - commands::audio::open_microphone_privacy_settings, - commands::audio::get_available_microphones, - commands::audio::set_selected_microphone, - commands::audio::get_selected_microphone, - commands::audio::get_available_output_devices, - commands::audio::set_selected_output_device, - commands::audio::get_selected_output_device, - commands::audio::play_test_sound, - commands::audio::check_custom_sounds, - commands::audio::set_clamshell_microphone, - commands::audio::get_clamshell_microphone, - commands::audio::is_recording, - commands::transcription::set_model_unload_timeout, - commands::transcription::get_model_load_status, - commands::transcription::unload_model_manually, - commands::history::get_history_entries, - commands::history::toggle_history_entry_saved, - commands::history::get_audio_file_path, - commands::history::delete_history_entry, - commands::history::update_history_limit, - commands::history::update_recording_retention_period, - helpers::clamshell::is_laptop, - ]); + let specta_builder = Builder::::new() + .commands(collect_commands![ + shortcut::change_binding, + shortcut::reset_binding, + shortcut::change_ptt_setting, + shortcut::change_audio_feedback_setting, + shortcut::change_audio_feedback_volume_setting, + shortcut::change_sound_theme_setting, + shortcut::change_start_hidden_setting, + shortcut::change_autostart_setting, + shortcut::change_translate_to_english_setting, + shortcut::change_selected_language_setting, + shortcut::change_overlay_position_setting, + shortcut::change_debug_mode_setting, + shortcut::change_word_correction_threshold_setting, + shortcut::change_extra_recording_buffer_setting, + shortcut::change_paste_method_setting, + shortcut::get_available_typing_tools, + shortcut::change_typing_tool_setting, + shortcut::change_external_script_path_setting, + shortcut::change_clipboard_handling_setting, + shortcut::change_auto_submit_setting, + shortcut::change_auto_submit_key_setting, + shortcut::change_post_process_enabled_setting, + shortcut::change_experimental_enabled_setting, + shortcut::change_post_process_base_url_setting, + shortcut::change_post_process_api_key_setting, + shortcut::change_post_process_model_setting, + shortcut::set_post_process_provider, + shortcut::fetch_post_process_models, + shortcut::add_post_process_prompt, + shortcut::update_post_process_prompt, + shortcut::delete_post_process_prompt, + shortcut::set_post_process_selected_prompt, + shortcut::update_custom_words, + shortcut::suspend_binding, + shortcut::resume_binding, + shortcut::change_mute_while_recording_setting, + shortcut::change_append_trailing_space_setting, + shortcut::change_lazy_stream_close_setting, + shortcut::change_app_language_setting, + shortcut::change_update_checks_setting, + shortcut::change_keyboard_implementation_setting, + shortcut::get_keyboard_implementation, + shortcut::change_show_tray_icon_setting, + shortcut::change_whisper_accelerator_setting, + shortcut::change_ort_accelerator_setting, + shortcut::get_available_accelerators, + shortcut::handy_keys::start_handy_keys_recording, + shortcut::handy_keys::stop_handy_keys_recording, + trigger_update_check, + show_main_window_command, + commands::cancel_operation, + commands::get_app_dir_path, + commands::get_app_settings, + commands::get_default_settings, + commands::get_log_dir_path, + commands::set_log_level, + commands::open_recordings_folder, + commands::open_log_dir, + commands::open_app_data_dir, + commands::check_apple_intelligence_available, + commands::initialize_enigo, + commands::initialize_shortcuts, + commands::models::get_available_models, + commands::models::get_model_info, + commands::models::download_model, + commands::models::delete_model, + commands::models::cancel_download, + commands::models::set_active_model, + commands::models::get_current_model, + commands::models::get_transcription_model_status, + commands::models::is_model_loading, + commands::models::has_any_models_available, + commands::models::has_any_models_or_downloads, + commands::audio::update_microphone_mode, + commands::audio::get_microphone_mode, + commands::audio::get_windows_microphone_permission_status, + commands::audio::open_microphone_privacy_settings, + commands::audio::get_available_microphones, + commands::audio::set_selected_microphone, + commands::audio::get_selected_microphone, + commands::audio::get_available_output_devices, + commands::audio::set_selected_output_device, + commands::audio::get_selected_output_device, + commands::audio::play_test_sound, + commands::audio::check_custom_sounds, + commands::audio::set_clamshell_microphone, + commands::audio::get_clamshell_microphone, + commands::audio::is_recording, + commands::transcription::set_model_unload_timeout, + commands::transcription::get_model_load_status, + commands::transcription::unload_model_manually, + commands::history::get_history_entries, + commands::history::toggle_history_entry_saved, + commands::history::get_audio_file_path, + commands::history::delete_history_entry, + commands::history::update_history_limit, + commands::history::update_recording_retention_period, + helpers::clamshell::is_laptop, + ]) + .events(collect_events![managers::history::HistoryUpdatePayload,]); #[cfg(debug_assertions)] // <- Only export on non-release builds specta_builder @@ -430,6 +432,8 @@ pub fn run(cli_args: CliArgs) { ) .expect("Failed to export typescript bindings"); + let invoke_handler = specta_builder.invoke_handler(); + #[allow(unused_mut)] let mut builder = tauri::Builder::default() .device_event_filter(tauri::DeviceEventFilter::Always) @@ -497,6 +501,8 @@ pub fn run(cli_args: CliArgs) { )) .manage(cli_args.clone()) .setup(move |app| { + specta_builder.mount_events(app); + // Create main window programmatically so we can set data_directory // for portable mode (redirects WebView2 cache to portable Data dir) let mut win_builder = @@ -580,7 +586,7 @@ pub fn run(cli_args: CliArgs) { } _ => {} }) - .invoke_handler(specta_builder.invoke_handler()) + .invoke_handler(invoke_handler) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| { diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs index 4f177cb..2c6d6ed 100644 --- a/src-tauri/src/managers/history.rs +++ b/src-tauri/src/managers/history.rs @@ -7,7 +7,8 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::fs; use std::path::PathBuf; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; +use tauri_specta::Event; use crate::audio_toolkit::save_wav_file; @@ -33,6 +34,23 @@ static MIGRATIONS: &[M] = &[ M::up("ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;"), ]; +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +pub struct PaginatedHistory { + pub entries: Vec, + pub has_more: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type, tauri_specta::Event)] +#[serde(tag = "action")] +pub enum HistoryUpdatePayload { + #[serde(rename = "added")] + Added { entry: HistoryEntry }, + #[serde(rename = "deleted")] + Deleted { id: i64 }, + #[serde(rename = "toggled")] + Toggled { id: i64 }, +} + #[derive(Clone, Debug, Serialize, Deserialize, Type)] pub struct HistoryEntry { pub id: i64, @@ -193,20 +211,30 @@ impl HistoryManager { save_wav_file(file_path, &audio_samples).await?; // Save to database - self.save_to_database( - file_name, + let id = self.save_to_database( + file_name.clone(), timestamp, - title, - transcription_text, - post_processed_text, - post_process_prompt, + title.clone(), + transcription_text.clone(), + post_processed_text.clone(), + post_process_prompt.clone(), )?; // Clean up old entries self.cleanup_old_entries()?; - // Emit history updated event - if let Err(e) = self.app_handle.emit("history-updated", ()) { + // Emit history updated event with the full entry + let entry = HistoryEntry { + id, + file_name, + timestamp, + saved: false, + title, + transcription_text, + post_processed_text, + post_process_prompt, + }; + if let Err(e) = (HistoryUpdatePayload::Added { entry }).emit(&self.app_handle) { error!("Failed to emit history-updated event: {}", e); } @@ -221,15 +249,16 @@ impl HistoryManager { transcription_text: String, post_processed_text: Option, post_process_prompt: Option, - ) -> Result<()> { + ) -> Result { let conn = self.get_connection()?; conn.execute( "INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt], )?; - debug!("Saved transcription to database"); - Ok(()) + let id = conn.last_insert_rowid(); + debug!("Saved transcription to database with id {}", id); + Ok(id) } pub fn cleanup_old_entries(&self) -> Result<()> { @@ -352,13 +381,15 @@ impl HistoryManager { Ok(()) } - pub async fn get_history_entries(&self) -> Result> { + pub async fn get_history_entries( + &self, + cursor: Option, + limit: Option, + ) -> Result { let conn = self.get_connection()?; - let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt FROM transcription_history ORDER BY timestamp DESC" - )?; + let limit = limit.map(|l| l.min(100)); - let rows = stmt.query_map([], |row| { + let row_mapper = |row: &rusqlite::Row| -> rusqlite::Result { Ok(HistoryEntry { id: row.get("id")?, file_name: row.get("file_name")?, @@ -369,14 +400,55 @@ impl HistoryManager { post_processed_text: row.get("post_processed_text")?, post_process_prompt: row.get("post_process_prompt")?, }) - })?; + }; - let mut entries = Vec::new(); - for row in rows { - entries.push(row?); + let mut entries: Vec = match (cursor, limit) { + (Some(cursor_id), Some(lim)) => { + let fetch_count = (lim + 1) as i64; + let mut stmt = conn.prepare( + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt + FROM transcription_history + WHERE id < ?1 + ORDER BY id DESC + LIMIT ?2", + )?; + let result = stmt + .query_map(params![cursor_id, fetch_count], row_mapper)? + .collect::, _>>()?; + result + } + (None, Some(lim)) => { + let fetch_count = (lim + 1) as i64; + let mut stmt = conn.prepare( + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt + FROM transcription_history + ORDER BY id DESC + LIMIT ?1", + )?; + let result = stmt + .query_map(params![fetch_count], row_mapper)? + .collect::, _>>()?; + result + } + (_, None) => { + let mut stmt = conn.prepare( + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt + FROM transcription_history + ORDER BY id DESC", + )?; + let result = stmt + .query_map([], row_mapper)? + .collect::, _>>()?; + result + } + }; + + let has_more = limit.is_some_and(|lim| entries.len() > lim); + if has_more { + entries.pop(); } - Ok(entries) + Ok(PaginatedHistory { entries, has_more }) } pub fn get_latest_entry(&self) -> Result> { @@ -430,7 +502,7 @@ impl HistoryManager { debug!("Toggled saved status for entry {}: {}", id, new_saved); // Emit history updated event - if let Err(e) = self.app_handle.emit("history-updated", ()) { + if let Err(e) = (HistoryUpdatePayload::Toggled { id }).emit(&self.app_handle) { error!("Failed to emit history-updated event: {}", e); } @@ -490,7 +562,7 @@ impl HistoryManager { debug!("Deleted history entry with id: {}", id); // Emit history updated event - if let Err(e) = self.app_handle.emit("history-updated", ()) { + if let Err(e) = (HistoryUpdatePayload::Deleted { id }).emit(&self.app_handle) { error!("Failed to emit history-updated event: {}", e); } diff --git a/src/bindings.ts b/src/bindings.ts index 8632615..5cde952 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -721,9 +721,9 @@ async unloadModelManually() : Promise> { else return { status: "error", error: e as any }; } }, -async getHistoryEntries() : Promise> { +async getHistoryEntries(cursor: number | null, limit: number | null) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("get_history_entries") }; + return { status: "ok", data: await TAURI_INVOKE("get_history_entries", { cursor, limit }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -788,6 +788,11 @@ async isLaptop() : Promise> { /** user-defined events **/ +export const events = __makeEvents__<{ +historyUpdatePayload: HistoryUpdatePayload +}>({ +historyUpdatePayload: "history-update-payload" +}) /** user-defined constants **/ @@ -804,6 +809,7 @@ export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" export type CustomSounds = { start: boolean; stop: boolean } export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary" export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null } +export type HistoryUpdatePayload = { action: "added"; entry: HistoryEntry } | { action: "deleted"; id: number } | { action: "toggled"; id: number } /** * Result of changing keyboard implementation */ @@ -820,6 +826,7 @@ export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_15" export type OrtAcceleratorSetting = "auto" | "cpu" | "cuda" | "directml" | "rocm" export type OverlayPosition = "none" | "top" | "bottom" +export type PaginatedHistory = { entries: HistoryEntry[]; has_more: boolean } export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script" export type PermissionAccess = "allowed" | "denied" | "unknown" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } diff --git a/src/components/settings/history/HistorySettings.tsx b/src/components/settings/history/HistorySettings.tsx index eaa7acc..909164f 100644 --- a/src/components/settings/history/HistorySettings.tsx +++ b/src/components/settings/history/HistorySettings.tsx @@ -1,14 +1,20 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { readFile } from "@tauri-apps/plugin-fs"; +import { Check, Copy, FolderOpen, Star, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { + commands, + events, + type HistoryEntry, + type HistoryUpdatePayload, +} from "@/bindings"; +import { useOsType } from "@/hooks/useOsType"; +import { formatDateTime } from "@/utils/dateFormat"; import { AudioPlayer } from "../../ui/AudioPlayer"; import { Button } from "../../ui/Button"; -import { Copy, Star, Check, Trash2, FolderOpen } from "lucide-react"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; -import { readFile } from "@tauri-apps/plugin-fs"; -import { commands, type HistoryEntry } from "@/bindings"; -import { formatDateTime } from "@/utils/dateFormat"; -import { useOsType } from "@/hooks/useOsType"; + +const PAGE_SIZE = 30; interface OpenRecordingsButtonProps { onClick: () => void; @@ -34,53 +40,109 @@ const OpenRecordingsButton: React.FC = ({ export const HistorySettings: React.FC = () => { const { t } = useTranslation(); const osType = useOsType(); - const [historyEntries, setHistoryEntries] = useState([]); + const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); + const [hasMore, setHasMore] = useState(true); + const sentinelRef = useRef(null); + const entriesRef = useRef([]); + const loadingRef = useRef(false); + + // Keep ref in sync for use in IntersectionObserver callback + useEffect(() => { + entriesRef.current = entries; + }, [entries]); + + const loadPage = useCallback(async (cursor?: number) => { + const isFirstPage = cursor === undefined; + if (!isFirstPage && loadingRef.current) return; + loadingRef.current = true; + + if (isFirstPage) setLoading(true); - const loadHistoryEntries = useCallback(async () => { try { - const result = await commands.getHistoryEntries(); + const result = await commands.getHistoryEntries( + cursor ?? null, + PAGE_SIZE, + ); if (result.status === "ok") { - setHistoryEntries(result.data); + const { entries: newEntries, has_more } = result.data; + setEntries((prev) => + isFirstPage ? newEntries : [...prev, ...newEntries], + ); + setHasMore(has_more); } } catch (error) { console.error("Failed to load history entries:", error); } finally { setLoading(false); + loadingRef.current = false; } }, []); + // Initial load useEffect(() => { - loadHistoryEntries(); + loadPage(); + }, [loadPage]); - // Listen for history update events - const setupListener = async () => { - const unlisten = await listen("history-updated", () => { - console.log("History updated, reloading entries..."); - loadHistoryEntries(); - }); + // Infinite scroll via IntersectionObserver + useEffect(() => { + if (loading) return; - // Return cleanup function - return unlisten; - }; + const sentinel = sentinelRef.current; + if (!sentinel || !hasMore) return; - let unlistenPromise = setupListener(); + const observer = new IntersectionObserver( + (observerEntries) => { + const first = observerEntries[0]; + if (first.isIntersecting) { + const lastEntry = entriesRef.current[entriesRef.current.length - 1]; + if (lastEntry) { + loadPage(lastEntry.id); + } + } + }, + { threshold: 0 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [loading, hasMore, loadPage]); + + // Listen for new entries added from the transcription pipeline + useEffect(() => { + const unlisten = events.historyUpdatePayload.listen((event) => { + const payload: HistoryUpdatePayload = event.payload; + if (payload.action === "added") { + setEntries((prev) => [payload.entry, ...prev]); + } + // "deleted" and "toggled" are handled by optimistic updates only, + // so we intentionally ignore them here to avoid double-mutation. + }); return () => { - unlistenPromise.then((unlisten) => { - if (unlisten) { - unlisten(); - } - }); + unlisten.then((fn) => fn()); }; - }, [loadHistoryEntries]); + }, []); const toggleSaved = async (id: number) => { + // Optimistic update + setEntries((prev) => + prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)), + ); try { - await commands.toggleHistoryEntrySaved(id); - // No need to reload here - the event listener will handle it + const result = await commands.toggleHistoryEntrySaved(id); + if (result.status !== "ok") { + // Revert on failure + setEntries((prev) => + prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)), + ); + } } catch (error) { console.error("Failed to toggle saved status:", error); + // Revert on failure + setEntries((prev) => + prev.map((e) => (e.id === id ? { ...e, saved: !e.saved } : e)), + ); } }; @@ -100,10 +162,8 @@ export const HistorySettings: React.FC = () => { if (osType === "linux") { const fileData = await readFile(result.data); const blob = new Blob([fileData], { type: "audio/wav" }); - return URL.createObjectURL(blob); } - return convertFileSrc(result.data, "asset"); } return null; @@ -116,70 +176,64 @@ export const HistorySettings: React.FC = () => { ); const deleteAudioEntry = async (id: number) => { + // Optimistically remove + setEntries((prev) => prev.filter((e) => e.id !== id)); try { - await commands.deleteHistoryEntry(id); + const result = await commands.deleteHistoryEntry(id); + if (result.status !== "ok") { + // Reload on failure + loadPage(); + } } catch (error) { - console.error("Failed to delete audio entry:", error); - throw error; + console.error("Failed to delete entry:", error); + loadPage(); } }; const openRecordingsFolder = async () => { try { - await commands.openRecordingsFolder(); + const result = await commands.openRecordingsFolder(); + if (result.status !== "ok") { + throw new Error(String(result.error)); + } } catch (error) { console.error("Failed to open recordings folder:", error); } }; + let content: React.ReactNode; + if (loading) { - return ( -
-
-
-
-

- {t("settings.history.title")} -

-
- -
-
-
- {t("settings.history.loading")} -
-
-
+ content = ( +
+ {t("settings.history.loading")}
); - } - - if (historyEntries.length === 0) { - return ( -
-
-
-
-

- {t("settings.history.title")} -

-
- -
-
-
- {t("settings.history.empty")} -
-
-
+ } else if (entries.length === 0) { + content = ( +
+ {t("settings.history.empty")}
); + } else { + content = ( + <> +
+ {entries.map((entry) => ( + toggleSaved(entry.id)} + onCopyText={() => copyToClipboard(entry.transcription_text)} + getAudioUrl={getAudioUrl} + deleteAudio={deleteAudioEntry} + /> + ))} +
+ {/* Sentinel for infinite scroll */} +
+ + ); } return ( @@ -197,18 +251,7 @@ export const HistorySettings: React.FC = () => { />
-
- {historyEntries.map((entry) => ( - toggleSaved(entry.id)} - onCopyText={() => copyToClipboard(entry.transcription_text)} - getAudioUrl={getAudioUrl} - deleteAudio={deleteAudioEntry} - /> - ))} -
+ {content}
@@ -249,7 +292,7 @@ const HistoryEntryComponent: React.FC = ({ await deleteAudio(entry.id); } catch (error) { console.error("Failed to delete entry:", error); - alert("Failed to delete entry. Please try again."); + alert(t("settings.history.deleteError")); } }; @@ -262,7 +305,7 @@ const HistoryEntryComponent: React.FC = ({