improve history performance (#1107)

* paginate history

* fixes

* fix

* format

* remove unecessary key
This commit is contained in:
CJ Pais 2026-03-21 12:06:29 +08:00 committed by GitHub
parent 8836d45532
commit e35f0a714e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 355 additions and 225 deletions

View file

@ -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<HistoryManager>>,
) -> Result<Vec<HistoryEntry>, String> {
cursor: Option<i64>,
limit: Option<usize>,
) -> Result<PaginatedHistory, String> {
history_manager
.get_history_entries()
.get_history_entries(cursor, limit)
.await
.map_err(|e| e.to_string())
}

View file

@ -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::<tauri::Wry>::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::<tauri::Wry>::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| {

View file

@ -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<HistoryEntry>,
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<String>,
post_process_prompt: Option<String>,
) -> Result<()> {
) -> Result<i64> {
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<Vec<HistoryEntry>> {
pub async fn get_history_entries(
&self,
cursor: Option<i64>,
limit: Option<usize>,
) -> Result<PaginatedHistory> {
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<HistoryEntry> {
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<HistoryEntry> = 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::<std::result::Result<Vec<_>, _>>()?;
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::<std::result::Result<Vec<_>, _>>()?;
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::<std::result::Result<Vec<_>, _>>()?;
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<Option<HistoryEntry>> {
@ -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);
}

View file

@ -721,9 +721,9 @@ async unloadModelManually() : Promise<Result<null, string>> {
else return { status: "error", error: e as any };
}
},
async getHistoryEntries() : Promise<Result<HistoryEntry[], string>> {
async getHistoryEntries(cursor: number | null, limit: number | null) : Promise<Result<PaginatedHistory, string>> {
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<Result<boolean, string>> {
/** 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 }

View file

@ -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<OpenRecordingsButtonProps> = ({
export const HistorySettings: React.FC = () => {
const { t } = useTranslation();
const osType = useOsType();
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [entries, setEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef<HTMLDivElement>(null);
const entriesRef = useRef<HistoryEntry[]>([]);
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 (
<div className="max-w-3xl w-full mx-auto space-y-6">
<div className="space-y-2">
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
{t("settings.history.title")}
</h2>
</div>
<OpenRecordingsButton
onClick={openRecordingsFolder}
label={t("settings.history.openFolder")}
/>
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="px-4 py-3 text-center text-text/60">
{t("settings.history.loading")}
</div>
</div>
</div>
content = (
<div className="px-4 py-3 text-center text-text/60">
{t("settings.history.loading")}
</div>
);
}
if (historyEntries.length === 0) {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<div className="space-y-2">
<div className="px-4 flex items-center justify-between">
<div>
<h2 className="text-xs font-medium text-mid-gray uppercase tracking-wide">
{t("settings.history.title")}
</h2>
</div>
<OpenRecordingsButton
onClick={openRecordingsFolder}
label={t("settings.history.openFolder")}
/>
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="px-4 py-3 text-center text-text/60">
{t("settings.history.empty")}
</div>
</div>
</div>
} else if (entries.length === 0) {
content = (
<div className="px-4 py-3 text-center text-text/60">
{t("settings.history.empty")}
</div>
);
} else {
content = (
<>
<div className="divide-y divide-mid-gray/20">
{entries.map((entry) => (
<HistoryEntryComponent
key={entry.id}
entry={entry}
onToggleSaved={() => toggleSaved(entry.id)}
onCopyText={() => copyToClipboard(entry.transcription_text)}
getAudioUrl={getAudioUrl}
deleteAudio={deleteAudioEntry}
/>
))}
</div>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</>
);
}
return (
@ -197,18 +251,7 @@ export const HistorySettings: React.FC = () => {
/>
</div>
<div className="bg-background border border-mid-gray/20 rounded-lg overflow-visible">
<div className="divide-y divide-mid-gray/20">
{historyEntries.map((entry) => (
<HistoryEntryComponent
key={entry.id}
entry={entry}
onToggleSaved={() => toggleSaved(entry.id)}
onCopyText={() => copyToClipboard(entry.transcription_text)}
getAudioUrl={getAudioUrl}
deleteAudio={deleteAudioEntry}
/>
))}
</div>
{content}
</div>
</div>
</div>
@ -249,7 +292,7 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
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<HistoryEntryProps> = ({
<div className="flex items-center gap-1">
<button
onClick={handleCopyText}
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
title={t("settings.history.copyToClipboard")}
>
{showCopied ? (